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
13pub fn wrap_text_line(text: &str, max_width: usize) -> Vec<String> {
18 if max_width == 0 {
19 return vec![text.to_string()];
20 }
21
22 let mut result = Vec::new();
23 let mut current_line = String::new();
24 let mut current_width = 0;
25
26 let mut chars = text.chars().peekable();
28 while chars.peek().is_some() {
29 let mut word = String::new();
31 let mut word_width = 0;
32
33 while let Some(&ch) = chars.peek() {
35 if ch != ' ' {
36 break;
37 }
38 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
39 word.push(ch);
40 word_width += ch_width;
41 chars.next();
42 }
43
44 while let Some(&ch) = chars.peek() {
46 if ch == ' ' {
47 break;
48 }
49 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
50 word.push(ch);
51 word_width += ch_width;
52 chars.next();
53 }
54
55 if word.is_empty() {
56 continue;
57 }
58
59 if current_width + word_width <= max_width {
61 current_line.push_str(&word);
62 current_width += word_width;
63 } else if current_line.is_empty() {
64 for ch in word.chars() {
66 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
67 if current_width + ch_width > max_width && !current_line.is_empty() {
68 result.push(current_line);
69 current_line = String::new();
70 current_width = 0;
71 }
72 current_line.push(ch);
73 current_width += ch_width;
74 }
75 } else {
76 result.push(current_line);
78 let trimmed = word.trim_start();
80 current_line = trimmed.to_string();
81 current_width = unicode_width::UnicodeWidthStr::width(trimmed);
82 }
83 }
84
85 if !current_line.is_empty() || result.is_empty() {
86 result.push(current_line);
87 }
88
89 result
90}
91
92pub fn wrap_text_lines(lines: &[String], max_width: usize) -> Vec<String> {
94 let mut result = Vec::new();
95 for line in lines {
96 if line.is_empty() {
97 result.push(String::new());
98 } else {
99 result.extend(wrap_text_line(line, max_width));
100 }
101 }
102 result
103}
104
105pub fn wrap_styled_lines(lines: &[StyledLine], max_width: usize) -> Vec<StyledLine> {
108 if max_width == 0 {
109 return lines.to_vec();
110 }
111
112 let mut result = Vec::new();
113
114 for line in lines {
115 let total_width: usize = line
117 .spans
118 .iter()
119 .map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
120 .sum();
121
122 if total_width <= max_width {
123 result.push(line.clone());
125 } else {
126 let mut segments: Vec<(String, Style, Option<String>)> = Vec::new();
128
129 for span in &line.spans {
130 let mut chars = span.text.chars().peekable();
132 while chars.peek().is_some() {
133 let mut segment = String::new();
134
135 while let Some(&ch) = chars.peek() {
137 if ch != ' ' {
138 break;
139 }
140 segment.push(ch);
141 chars.next();
142 }
143
144 while let Some(&ch) = chars.peek() {
146 if ch == ' ' {
147 break;
148 }
149 segment.push(ch);
150 chars.next();
151 }
152
153 if !segment.is_empty() {
154 segments.push((segment, span.style, span.link_url.clone()));
155 }
156 }
157 }
158
159 let mut current_line = StyledLine::new();
161 let mut current_width = 0;
162
163 for (segment, style, link_url) in segments {
164 let seg_width = unicode_width::UnicodeWidthStr::width(segment.as_str());
165
166 if current_width + seg_width <= max_width {
167 current_line.push_with_link(segment, style, link_url);
169 current_width += seg_width;
170 } else if current_width == 0 {
171 let mut remaining = segment.as_str();
173 while !remaining.is_empty() {
174 let available = max_width.saturating_sub(current_width);
175 if available == 0 {
176 result.push(current_line);
177 current_line = StyledLine::new();
178 current_width = 0;
179 continue;
180 }
181
182 let mut take_chars = 0;
184 let mut take_width = 0;
185 for ch in remaining.chars() {
186 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
187 if take_width + w > available && take_chars > 0 {
188 break;
189 }
190 take_width += w;
191 take_chars += 1;
192 }
193
194 let byte_idx = remaining
195 .char_indices()
196 .nth(take_chars)
197 .map(|(i, _)| i)
198 .unwrap_or(remaining.len());
199 let (take, rest) = remaining.split_at(byte_idx);
200 current_line.push_with_link(take.to_string(), style, link_url.clone());
201 current_width += take_width;
202 remaining = rest;
203 }
204 } else {
205 result.push(current_line);
207 current_line = StyledLine::new();
208 current_line.push_with_link(segment, style, link_url);
210 current_width = seg_width;
211 }
212 }
213
214 if !current_line.spans.is_empty() {
215 result.push(current_line);
216 }
217 }
218 }
219
220 result
221}
222
223#[derive(Debug, Clone, PartialEq)]
225pub struct StyledSpan {
226 pub text: String,
227 pub style: Style,
228 pub link_url: Option<String>,
230}
231
232#[derive(Debug, Clone, PartialEq)]
234pub struct StyledLine {
235 pub spans: Vec<StyledSpan>,
236}
237
238impl StyledLine {
239 pub fn new() -> Self {
240 Self { spans: Vec::new() }
241 }
242
243 pub fn push(&mut self, text: String, style: Style) {
244 self.spans.push(StyledSpan {
245 text,
246 style,
247 link_url: None,
248 });
249 }
250
251 pub fn push_with_link(&mut self, text: String, style: Style, link_url: Option<String>) {
253 self.spans.push(StyledSpan {
254 text,
255 style,
256 link_url,
257 });
258 }
259
260 pub fn link_at_column(&self, column: usize) -> Option<&str> {
263 let mut current_col = 0;
264 for span in &self.spans {
265 let span_width = unicode_width::UnicodeWidthStr::width(span.text.as_str());
266 if column >= current_col && column < current_col + span_width {
267 return span.link_url.as_deref();
269 }
270 current_col += span_width;
271 }
272 None
273 }
274
275 pub fn plain_text(&self) -> String {
277 self.spans.iter().map(|s| s.text.as_str()).collect()
278 }
279}
280
281impl Default for StyledLine {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287fn highlight_code_to_styled_lines(
289 code: &str,
290 spans: &[HighlightSpan],
291 theme: &crate::view::theme::Theme,
292) -> Vec<StyledLine> {
293 let mut result = vec![StyledLine::new()];
294 let code_bg = theme.inline_code_bg;
295 let default_fg = theme.help_key_fg;
296
297 let bytes = code.as_bytes();
298 let mut pos = 0;
299
300 for span in spans {
301 if span.range.start > pos {
303 let text = String::from_utf8_lossy(&bytes[pos..span.range.start]);
304 add_code_text_to_lines(
305 &mut result,
306 &text,
307 Style::default().fg(default_fg).bg(code_bg),
308 );
309 }
310
311 let text = String::from_utf8_lossy(&bytes[span.range.start..span.range.end]);
313 add_code_text_to_lines(
314 &mut result,
315 &text,
316 Style::default().fg(span.color).bg(code_bg),
317 );
318
319 pos = span.range.end;
320 }
321
322 if pos < bytes.len() {
324 let text = String::from_utf8_lossy(&bytes[pos..]);
325 add_code_text_to_lines(
326 &mut result,
327 &text,
328 Style::default().fg(default_fg).bg(code_bg),
329 );
330 }
331
332 result
333}
334
335fn add_code_text_to_lines(lines: &mut Vec<StyledLine>, text: &str, style: Style) {
337 for (i, part) in text.split('\n').enumerate() {
338 if i > 0 {
339 lines.push(StyledLine::new());
340 }
341 if !part.is_empty() {
342 if let Some(line) = lines.last_mut() {
343 line.push(part.to_string(), style);
344 }
345 }
346 }
347}
348
349pub fn parse_markdown(
354 text: &str,
355 theme: &crate::view::theme::Theme,
356 registry: Option<&GrammarRegistry>,
357) -> Vec<StyledLine> {
358 let mut options = Options::empty();
359 options.insert(Options::ENABLE_STRIKETHROUGH);
360
361 let parser = Parser::new_ext(text, options);
362 let mut lines: Vec<StyledLine> = vec![StyledLine::new()];
363
364 let mut style_stack: Vec<Style> = vec![Style::default()];
366 let mut in_code_block = false;
367 let mut code_block_lang = String::new();
368 let mut current_link_url: Option<String> = None;
370
371 for event in parser {
372 match event {
373 Event::Start(tag) => {
374 match tag {
375 Tag::Strong => {
376 let current = *style_stack.last().unwrap_or(&Style::default());
377 style_stack.push(current.add_modifier(Modifier::BOLD));
378 }
379 Tag::Emphasis => {
380 let current = *style_stack.last().unwrap_or(&Style::default());
381 style_stack.push(current.add_modifier(Modifier::ITALIC));
382 }
383 Tag::Strikethrough => {
384 let current = *style_stack.last().unwrap_or(&Style::default());
385 style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
386 }
387 Tag::CodeBlock(kind) => {
388 in_code_block = true;
389 code_block_lang = match kind {
390 pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
391 pulldown_cmark::CodeBlockKind::Indented => String::new(),
392 };
393 if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
395 lines.push(StyledLine::new());
396 }
397 }
398 Tag::Heading { .. } => {
399 let current = *style_stack.last().unwrap_or(&Style::default());
400 style_stack
401 .push(current.add_modifier(Modifier::BOLD).fg(theme.help_key_fg));
402 }
403 Tag::Link { dest_url, .. } => {
404 let current = *style_stack.last().unwrap_or(&Style::default());
405 style_stack
406 .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
407 current_link_url = Some(dest_url.to_string());
409 }
410 Tag::Image { .. } => {
411 let current = *style_stack.last().unwrap_or(&Style::default());
412 style_stack
413 .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
414 }
415 Tag::List(_) | Tag::Item => {
416 if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
418 lines.push(StyledLine::new());
419 }
420 }
421 Tag::Paragraph => {
422 let has_prior_content = lines.iter().any(|l| !l.spans.is_empty());
425 if has_prior_content {
426 lines.push(StyledLine::new());
427 }
428 }
429 _ => {}
430 }
431 }
432 Event::End(tag_end) => {
433 match tag_end {
434 TagEnd::Strong
435 | TagEnd::Emphasis
436 | TagEnd::Strikethrough
437 | TagEnd::Heading(_)
438 | TagEnd::Image => {
439 style_stack.pop();
440 }
441 TagEnd::Link => {
442 style_stack.pop();
443 current_link_url = None;
445 }
446 TagEnd::CodeBlock => {
447 in_code_block = false;
448 code_block_lang.clear();
449 lines.push(StyledLine::new());
451 }
452 TagEnd::Paragraph => {
453 lines.push(StyledLine::new());
455 }
456 TagEnd::Item => {
457 }
459 _ => {}
460 }
461 }
462 Event::Text(text) => {
463 if in_code_block {
464 let spans = if let Some(reg) = registry {
466 if !code_block_lang.is_empty() {
467 let s = highlight_string(&text, &code_block_lang, reg, theme);
468 let highlighted_bytes: usize =
470 s.iter().map(|span| span.range.end - span.range.start).sum();
471 let non_ws_bytes =
472 text.bytes().filter(|b| !b.is_ascii_whitespace()).count();
473 let good_coverage =
474 non_ws_bytes == 0 || highlighted_bytes * 5 >= non_ws_bytes;
475 if good_coverage {
476 s
477 } else {
478 Vec::new()
479 }
480 } else {
481 Vec::new()
482 }
483 } else {
484 Vec::new()
485 };
486
487 if !spans.is_empty() {
488 let highlighted_lines =
489 highlight_code_to_styled_lines(&text, &spans, theme);
490 for (i, styled_line) in highlighted_lines.into_iter().enumerate() {
491 if i > 0 {
492 lines.push(StyledLine::new());
493 }
494 if let Some(current_line) = lines.last_mut() {
496 for span in styled_line.spans {
497 current_line.push(span.text, span.style);
498 }
499 }
500 }
501 } else {
502 let code_style = Style::default()
504 .fg(theme.help_key_fg)
505 .bg(theme.inline_code_bg);
506 for (i, part) in text.split('\n').enumerate() {
507 if i > 0 {
508 lines.push(StyledLine::new());
509 }
510 if !part.is_empty() {
511 if let Some(line) = lines.last_mut() {
512 line.push(part.to_string(), code_style);
513 }
514 }
515 }
516 }
517 } else {
518 let current_style = *style_stack.last().unwrap_or(&Style::default());
519 for (i, part) in text.split('\n').enumerate() {
521 if i > 0 {
522 lines.push(StyledLine::new());
523 }
524 if !part.is_empty() {
525 if let Some(line) = lines.last_mut() {
526 line.push_with_link(
528 part.to_string(),
529 current_style,
530 current_link_url.clone(),
531 );
532 }
533 }
534 }
535 }
536 }
537 Event::Code(code) => {
538 let style = Style::default()
540 .fg(theme.help_key_fg)
541 .bg(theme.inline_code_bg);
542 if let Some(line) = lines.last_mut() {
543 line.push(code.to_string(), style);
544 }
545 }
546 Event::SoftBreak => {
547 lines.push(StyledLine::new());
551 }
552 Event::HardBreak => {
553 lines.push(StyledLine::new());
555 }
556 Event::Rule => {
557 lines.push(StyledLine::new());
559 if let Some(line) = lines.last_mut() {
560 line.push("─".repeat(40), Style::default().fg(Color::DarkGray));
561 }
562 lines.push(StyledLine::new());
563 }
564 _ => {}
565 }
566 }
567
568 while lines.last().map(|l| l.spans.is_empty()).unwrap_or(false) {
570 lines.pop();
571 }
572
573 lines
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::view::theme;
580 use crate::view::theme::Theme;
581
582 fn get_line_text(line: &StyledLine) -> String {
583 line.spans.iter().map(|s| s.text.as_str()).collect()
584 }
585
586 fn has_modifier(line: &StyledLine, modifier: Modifier) -> bool {
587 line.spans
588 .iter()
589 .any(|s| s.style.add_modifier.contains(modifier))
590 }
591
592 #[test]
593 fn test_plain_text() {
594 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
595 let lines = parse_markdown("Hello world", &theme, None);
596
597 assert_eq!(lines.len(), 1);
598 assert_eq!(get_line_text(&lines[0]), "Hello world");
599 }
600
601 #[test]
602 fn test_bold_text() {
603 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
604 let lines = parse_markdown("This is **bold** text", &theme, None);
605
606 assert_eq!(lines.len(), 1);
607 assert_eq!(get_line_text(&lines[0]), "This is bold text");
608
609 let bold_span = lines[0].spans.iter().find(|s| s.text == "bold");
611 assert!(bold_span.is_some(), "Should have a 'bold' span");
612 assert!(
613 bold_span
614 .unwrap()
615 .style
616 .add_modifier
617 .contains(Modifier::BOLD),
618 "Bold span should have BOLD modifier"
619 );
620 }
621
622 #[test]
623 fn test_italic_text() {
624 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
625 let lines = parse_markdown("This is *italic* text", &theme, None);
626
627 assert_eq!(lines.len(), 1);
628 assert_eq!(get_line_text(&lines[0]), "This is italic text");
629
630 let italic_span = lines[0].spans.iter().find(|s| s.text == "italic");
631 assert!(italic_span.is_some(), "Should have an 'italic' span");
632 assert!(
633 italic_span
634 .unwrap()
635 .style
636 .add_modifier
637 .contains(Modifier::ITALIC),
638 "Italic span should have ITALIC modifier"
639 );
640 }
641
642 #[test]
643 fn test_strikethrough_text() {
644 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
645 let lines = parse_markdown("This is ~~deleted~~ text", &theme, None);
646
647 assert_eq!(lines.len(), 1);
648 assert_eq!(get_line_text(&lines[0]), "This is deleted text");
649
650 let strike_span = lines[0].spans.iter().find(|s| s.text == "deleted");
651 assert!(strike_span.is_some(), "Should have a 'deleted' span");
652 assert!(
653 strike_span
654 .unwrap()
655 .style
656 .add_modifier
657 .contains(Modifier::CROSSED_OUT),
658 "Strikethrough span should have CROSSED_OUT modifier"
659 );
660 }
661
662 #[test]
663 fn test_inline_code() {
664 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
665 let lines = parse_markdown("Use `println!` to print", &theme, None);
666
667 assert_eq!(lines.len(), 1);
668 assert_eq!(get_line_text(&lines[0]), "Use println! to print");
670
671 let code_span = lines[0].spans.iter().find(|s| s.text.contains("println"));
673 assert!(code_span.is_some(), "Should have a code span");
674 assert!(
675 code_span.unwrap().style.bg.is_some(),
676 "Inline code should have background color"
677 );
678 }
679
680 #[test]
681 fn test_code_block() {
682 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
683 let lines = parse_markdown("```rust\nfn main() {}\n```", &theme, None);
684
685 let code_line = lines.iter().find(|l| get_line_text(l).contains("fn"));
687 assert!(code_line.is_some(), "Should have code block content");
688
689 let has_bg = code_line
692 .unwrap()
693 .spans
694 .iter()
695 .any(|s| s.style.bg.is_some());
696 assert!(has_bg, "Code block should have background color");
697 }
698
699 #[test]
700 fn test_code_block_syntax_highlighting() {
701 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
702 let registry =
703 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
704 let markdown = "```rust\nfn main() {\n println!(\"Hello\");\n}\n```";
706 let lines = parse_markdown(markdown, &theme, Some(®istry));
707
708 assert!(!lines.is_empty(), "Should have parsed lines");
710
711 let mut colors_used = std::collections::HashSet::new();
713 for line in &lines {
714 for span in &line.spans {
715 if let Some(fg) = span.style.fg {
716 colors_used.insert(format!("{:?}", fg));
717 }
718 }
719 }
720
721 assert!(
724 colors_used.len() > 1,
725 "Code block should have multiple colors for syntax highlighting, got: {:?}",
726 colors_used
727 );
728
729 let all_text: String = lines.iter().map(get_line_text).collect::<Vec<_>>().join("");
731 assert!(all_text.contains("fn"), "Should contain 'fn' keyword");
732 assert!(all_text.contains("main"), "Should contain 'main'");
733 assert!(all_text.contains("println"), "Should contain 'println'");
734 }
735
736 #[test]
737 fn test_code_block_unknown_language_fallback() {
738 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
739 let markdown = "```unknownlang\nsome code here\n```";
741 let lines = parse_markdown(markdown, &theme, None);
742
743 assert!(!lines.is_empty(), "Should have parsed lines");
745
746 let all_text: String = lines.iter().map(get_line_text).collect::<Vec<_>>().join("");
748 assert!(
749 all_text.contains("some code here"),
750 "Should contain the code"
751 );
752
753 let code_line = lines
755 .iter()
756 .find(|l| get_line_text(l).contains("some code"));
757 if let Some(line) = code_line {
758 for span in &line.spans {
759 assert!(span.style.bg.is_some(), "Code should have background color");
760 }
761 }
762 }
763
764 #[test]
765 fn test_heading() {
766 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
767 let lines = parse_markdown("# Heading\n\nContent", &theme, None);
768
769 let heading_line = &lines[0];
771 assert!(
772 has_modifier(heading_line, Modifier::BOLD),
773 "Heading should be bold"
774 );
775 assert_eq!(get_line_text(heading_line), "Heading");
776 }
777
778 #[test]
779 fn test_link() {
780 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
781 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
782
783 assert_eq!(lines.len(), 1);
784 assert_eq!(get_line_text(&lines[0]), "Click here for more");
785
786 let link_span = lines[0].spans.iter().find(|s| s.text == "here");
788 assert!(link_span.is_some(), "Should have 'here' span");
789 let style = link_span.unwrap().style;
790 assert!(
791 style.add_modifier.contains(Modifier::UNDERLINED),
792 "Link should be underlined"
793 );
794 assert_eq!(style.fg, Some(Color::Cyan), "Link should be cyan");
795 }
796
797 #[test]
798 fn test_link_url_stored() {
799 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
800 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
801
802 assert_eq!(lines.len(), 1);
803
804 let link_span = lines[0].spans.iter().find(|s| s.text == "here");
806 assert!(link_span.is_some(), "Should have 'here' span");
807 assert_eq!(
808 link_span.unwrap().link_url,
809 Some("https://example.com".to_string()),
810 "Link span should store the URL"
811 );
812
813 let click_span = lines[0].spans.iter().find(|s| s.text == "Click ");
815 assert!(click_span.is_some(), "Should have 'Click ' span");
816 assert_eq!(
817 click_span.unwrap().link_url,
818 None,
819 "Non-link span should not have URL"
820 );
821 }
822
823 #[test]
824 fn test_link_at_column() {
825 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
826 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
827
828 assert_eq!(lines.len(), 1);
829 let line = &lines[0];
830
831 assert_eq!(
834 line.link_at_column(0),
835 None,
836 "Column 0 should not be a link"
837 );
838 assert_eq!(
839 line.link_at_column(5),
840 None,
841 "Column 5 should not be a link"
842 );
843
844 assert_eq!(
846 line.link_at_column(6),
847 Some("https://example.com"),
848 "Column 6 should be the link"
849 );
850 assert_eq!(
851 line.link_at_column(9),
852 Some("https://example.com"),
853 "Column 9 should be the link"
854 );
855
856 assert_eq!(
858 line.link_at_column(10),
859 None,
860 "Column 10 should not be a link"
861 );
862 }
863
864 #[test]
865 fn test_unordered_list() {
866 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
867 let lines = parse_markdown("- Item 1\n- Item 2\n- Item 3", &theme, None);
868
869 assert!(lines.len() >= 3, "Should have at least 3 lines for 3 items");
871
872 let all_text: String = lines.iter().map(get_line_text).collect();
873 assert!(all_text.contains("Item 1"), "Should contain Item 1");
874 assert!(all_text.contains("Item 2"), "Should contain Item 2");
875 assert!(all_text.contains("Item 3"), "Should contain Item 3");
876 }
877
878 #[test]
879 fn test_paragraph_separation() {
880 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
881 let lines = parse_markdown("First paragraph.\n\nSecond paragraph.", &theme, None);
882
883 assert_eq!(
885 lines.len(),
886 3,
887 "Should have 3 lines (para, blank, para), got: {:?}",
888 lines.iter().map(get_line_text).collect::<Vec<_>>()
889 );
890
891 assert_eq!(get_line_text(&lines[0]), "First paragraph.");
892 assert!(
893 lines[1].spans.is_empty(),
894 "Second line should be empty (paragraph break)"
895 );
896 assert_eq!(get_line_text(&lines[2]), "Second paragraph.");
897 }
898
899 #[test]
900 fn test_soft_break_becomes_newline() {
901 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
902 let lines = parse_markdown("Line one\nLine two", &theme, None);
904
905 assert!(
907 lines.len() >= 2,
908 "Soft break should create separate lines, got {} lines",
909 lines.len()
910 );
911 let all_text: String = lines.iter().map(get_line_text).collect();
912 assert!(
913 all_text.contains("one") && all_text.contains("two"),
914 "Should contain both lines"
915 );
916 }
917
918 #[test]
919 fn test_hard_break() {
920 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
921 let lines = parse_markdown("Line one \nLine two", &theme, None);
923
924 assert!(lines.len() >= 2, "Hard break should create multiple lines");
926 }
927
928 #[test]
929 fn test_horizontal_rule() {
930 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
931 let lines = parse_markdown("Above\n\n---\n\nBelow", &theme, None);
932
933 let has_rule = lines.iter().any(|l| get_line_text(l).contains("─"));
935 assert!(has_rule, "Should contain horizontal rule character");
936 }
937
938 #[test]
939 fn test_nested_formatting() {
940 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
941 let lines = parse_markdown("This is ***bold and italic*** text", &theme, None);
942
943 assert_eq!(lines.len(), 1);
944
945 let nested_span = lines[0].spans.iter().find(|s| s.text == "bold and italic");
947 assert!(nested_span.is_some(), "Should have nested formatted span");
948
949 let style = nested_span.unwrap().style;
950 assert!(
951 style.add_modifier.contains(Modifier::BOLD),
952 "Should be bold"
953 );
954 assert!(
955 style.add_modifier.contains(Modifier::ITALIC),
956 "Should be italic"
957 );
958 }
959
960 #[test]
961 fn test_lsp_hover_docstring() {
962 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
964 let markdown = "```python\n(class) Path\n```\n\nPurePath subclass that can make system calls.\n\nPath represents a filesystem path.";
965
966 let lines = parse_markdown(markdown, &theme, None);
967
968 assert!(lines.len() >= 3, "Should have multiple sections");
970
971 let code_line = lines.iter().find(|l| get_line_text(l).contains("Path"));
973 assert!(code_line.is_some(), "Should have code block with Path");
974
975 let all_text: String = lines.iter().map(get_line_text).collect();
977 assert!(
978 all_text.contains("PurePath subclass"),
979 "Should contain docstring"
980 );
981 }
982
983 #[test]
984 fn test_python_docstring_formatting() {
985 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
987 let markdown = "Keyword Arguments:\n - prog -- The name\n - usage -- A usage message";
988 let lines = parse_markdown(markdown, &theme, None);
989
990 assert!(
992 lines.len() >= 3,
993 "Should have multiple lines for keyword args list, got {} lines: {:?}",
994 lines.len(),
995 lines.iter().map(get_line_text).collect::<Vec<_>>()
996 );
997 }
998
999 #[test]
1000 fn test_empty_input() {
1001 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1002 let lines = parse_markdown("", &theme, None);
1003
1004 assert!(
1006 lines.is_empty() || (lines.len() == 1 && lines[0].spans.is_empty()),
1007 "Empty input should produce empty output"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_only_whitespace() {
1013 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1014 let lines = parse_markdown(" \n\n ", &theme, None);
1015
1016 for line in &lines {
1018 let text = get_line_text(line);
1019 assert!(
1020 text.trim().is_empty(),
1021 "Whitespace-only input should not produce content"
1022 );
1023 }
1024 }
1025
1026 #[test]
1029 fn test_wrap_text_line_at_word_boundaries() {
1030 let text = "Path represents a filesystem path but unlike PurePath also offers methods";
1032 let wrapped = wrap_text_line(text, 30);
1033
1034 for (i, line) in wrapped.iter().enumerate() {
1036 if !line.is_empty() {
1038 assert!(
1039 !line.starts_with(' '),
1040 "Line {} should not start with space: {:?}",
1041 i,
1042 line
1043 );
1044 }
1045
1046 let line_width = unicode_width::UnicodeWidthStr::width(line.as_str());
1048 assert!(
1049 line_width <= 30,
1050 "Line {} exceeds max width: {} > 30, content: {:?}",
1051 i,
1052 line_width,
1053 line
1054 );
1055 }
1056
1057 let original_words: Vec<&str> = text.split_whitespace().collect();
1060 let wrapped_words: Vec<&str> = wrapped
1061 .iter()
1062 .flat_map(|line| line.split_whitespace())
1063 .collect();
1064 assert_eq!(
1065 original_words, wrapped_words,
1066 "Words should be preserved without breaking mid-word"
1067 );
1068
1069 assert_eq!(
1071 wrapped[0], "Path represents a filesystem",
1072 "First line should break at word boundary"
1073 );
1074 assert_eq!(
1075 wrapped[1], "path but unlike PurePath also",
1076 "Second line should contain next words (30 chars fits)"
1077 );
1078 assert_eq!(
1079 wrapped[2], "offers methods",
1080 "Third line should contain remaining words"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_wrap_text_line_long_word() {
1086 let text = "supercalifragilisticexpialidocious";
1088 let wrapped = wrap_text_line(text, 10);
1089
1090 assert!(
1091 wrapped.len() > 1,
1092 "Long word should be split into multiple lines"
1093 );
1094
1095 for line in &wrapped {
1097 let width = unicode_width::UnicodeWidthStr::width(line.as_str());
1098 assert!(width <= 10, "Line should not exceed max width: {}", line);
1099 }
1100
1101 let rejoined: String = wrapped.join("");
1103 assert_eq!(rejoined, text, "Content should be preserved");
1104 }
1105
1106 #[test]
1107 fn test_wrap_text_line_empty() {
1108 let wrapped = wrap_text_line("", 30);
1109 assert_eq!(wrapped.len(), 1);
1110 assert_eq!(wrapped[0], "");
1111 }
1112
1113 #[test]
1114 fn test_wrap_text_line_fits() {
1115 let text = "Short text";
1116 let wrapped = wrap_text_line(text, 30);
1117 assert_eq!(wrapped.len(), 1);
1118 assert_eq!(wrapped[0], text);
1119 }
1120
1121 #[test]
1122 fn test_wrap_styled_lines_long_hover_content() {
1123 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1125
1126 let long_text = "def very_long_function_name(param1: str, param2: int, param3: float, param4: list, param5: dict) -> tuple[str, int, float]";
1128 let markdown = format!("```python\n{}\n```", long_text);
1129
1130 let lines = parse_markdown(&markdown, &theme, None);
1131
1132 assert!(!lines.is_empty(), "Should have parsed lines");
1134
1135 let wrapped = wrap_styled_lines(&lines, 40);
1137
1138 assert!(
1140 wrapped.len() > lines.len(),
1141 "Long line should wrap into multiple lines. Original: {}, Wrapped: {}",
1142 lines.len(),
1143 wrapped.len()
1144 );
1145
1146 for (i, line) in wrapped.iter().enumerate() {
1148 let line_width: usize = line
1149 .spans
1150 .iter()
1151 .map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
1152 .sum();
1153 assert!(
1154 line_width <= 40,
1155 "Wrapped line {} exceeds max width: {} > 40, content: {:?}",
1156 i,
1157 line_width,
1158 line.spans
1159 .iter()
1160 .map(|s| s.text.as_str())
1161 .collect::<Vec<_>>()
1162 );
1163 }
1164
1165 let original_text: String = lines
1167 .iter()
1168 .flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
1169 .collect();
1170 let wrapped_text: String = wrapped
1171 .iter()
1172 .flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
1173 .collect();
1174 assert_eq!(
1175 original_text, wrapped_text,
1176 "Content should be preserved after wrapping"
1177 );
1178 }
1179
1180 #[test]
1181 fn test_wrap_styled_lines_preserves_style() {
1182 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1183 let lines = parse_markdown("**bold text that is quite long**", &theme, None);
1184
1185 let wrapped = wrap_styled_lines(&lines, 15);
1186
1187 for line in &wrapped {
1189 for span in &line.spans {
1190 if !span.text.trim().is_empty() {
1191 assert!(
1192 span.style.add_modifier.contains(Modifier::BOLD),
1193 "Style should be preserved after wrapping: {:?}",
1194 span.text
1195 );
1196 }
1197 }
1198 }
1199 }
1200
1201 #[test]
1202 fn test_wrap_text_lines_multiple() {
1203 let lines = vec![
1204 "Short".to_string(),
1205 "This is a longer line that needs wrapping".to_string(),
1206 "".to_string(),
1207 "Another line".to_string(),
1208 ];
1209
1210 let wrapped = wrap_text_lines(&lines, 20);
1211
1212 assert!(
1214 wrapped.iter().any(|l| l.is_empty()),
1215 "Should preserve empty lines"
1216 );
1217
1218 for line in &wrapped {
1220 let width = unicode_width::UnicodeWidthStr::width(line.as_str());
1221 assert!(width <= 20, "Line exceeds max width: {}", line);
1222 }
1223 }
1224}