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 if self.chars.peek().is_none() {
252 return None;
253 }
254
255 let mut word = String::new();
256 let mut width = 0;
257
258 while let Some(&ch) = self.chars.peek() {
260 if !is_space(ch) {
261 break;
262 }
263 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
264 word.push(ch);
265 width += w;
266 self.chars.next();
267 }
268
269 while let Some(&ch) = self.chars.peek() {
271 if is_space(ch) {
272 break;
273 }
274 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
275 word.push(ch);
276 width += w;
277 self.chars.next();
278 }
279
280 if word.is_empty() {
281 None
282 } else {
283 Some((word, width))
284 }
285 }
286}
287
288#[derive(Debug, Clone, PartialEq)]
290pub struct StyledSpan {
291 pub text: String,
292 pub style: Style,
293 pub link_url: Option<String>,
295}
296
297#[derive(Debug, Clone, PartialEq)]
299pub struct StyledLine {
300 pub spans: Vec<StyledSpan>,
301}
302
303impl StyledLine {
304 pub fn new() -> Self {
305 Self { spans: Vec::new() }
306 }
307
308 pub fn push(&mut self, text: String, style: Style) {
309 self.spans.push(StyledSpan {
310 text,
311 style,
312 link_url: None,
313 });
314 }
315
316 pub fn push_with_link(&mut self, text: String, style: Style, link_url: Option<String>) {
318 self.spans.push(StyledSpan {
319 text,
320 style,
321 link_url,
322 });
323 }
324
325 pub fn link_at_column(&self, column: usize) -> Option<&str> {
328 let mut current_col = 0;
329 for span in &self.spans {
330 let span_width = unicode_width::UnicodeWidthStr::width(span.text.as_str());
331 if column >= current_col && column < current_col + span_width {
332 return span.link_url.as_deref();
334 }
335 current_col += span_width;
336 }
337 None
338 }
339
340 pub fn plain_text(&self) -> String {
342 self.spans.iter().map(|s| s.text.as_str()).collect()
343 }
344}
345
346impl Default for StyledLine {
347 fn default() -> Self {
348 Self::new()
349 }
350}
351
352fn highlight_code_to_styled_lines(
354 code: &str,
355 spans: &[HighlightSpan],
356 theme: &crate::view::theme::Theme,
357) -> Vec<StyledLine> {
358 let mut result = vec![StyledLine::new()];
359 let code_bg = theme.inline_code_bg;
360 let default_fg = theme.help_key_fg;
361
362 let bytes = code.as_bytes();
363 let mut pos = 0;
364
365 for span in spans {
366 if span.range.start > pos {
368 let text = String::from_utf8_lossy(&bytes[pos..span.range.start]);
369 add_text_to_lines(
370 &mut result,
371 &text,
372 Style::default().fg(default_fg).bg(code_bg),
373 None,
374 );
375 }
376
377 let text = String::from_utf8_lossy(&bytes[span.range.start..span.range.end]);
379 add_text_to_lines(
380 &mut result,
381 &text,
382 Style::default().fg(span.color).bg(code_bg),
383 None,
384 );
385
386 pos = span.range.end;
387 }
388
389 if pos < bytes.len() {
391 let text = String::from_utf8_lossy(&bytes[pos..]);
392 add_text_to_lines(
393 &mut result,
394 &text,
395 Style::default().fg(default_fg).bg(code_bg),
396 None,
397 );
398 }
399
400 result
401}
402
403fn add_text_to_lines(
407 lines: &mut Vec<StyledLine>,
408 text: &str,
409 style: Style,
410 link_url: Option<String>,
411) {
412 for (i, part) in text.split('\n').enumerate() {
413 if i > 0 {
414 lines.push(StyledLine::new());
415 }
416 if !part.is_empty() {
417 if let Some(line) = lines.last_mut() {
418 line.push_with_link(part.to_string(), style, link_url.clone());
419 }
420 }
421 }
422}
423
424fn preserve_leading_whitespace(text: &str) -> String {
429 text.lines()
430 .map(|line| {
431 let indent = line.len() - line.trim_start_matches(' ').len();
432 if indent > 0 {
433 format!("{}{}", "\u{00A0}".repeat(indent), &line[indent..])
434 } else {
435 line.to_string()
436 }
437 })
438 .collect::<Vec<_>>()
439 .join("\n")
440}
441
442pub fn parse_markdown(
447 text: &str,
448 theme: &crate::view::theme::Theme,
449 registry: Option<&GrammarRegistry>,
450) -> Vec<StyledLine> {
451 let preserved = preserve_leading_whitespace(text);
454
455 let mut options = Options::empty();
456 options.insert(Options::ENABLE_STRIKETHROUGH);
457
458 let parser = Parser::new_ext(&preserved, options);
459 let mut lines: Vec<StyledLine> = vec![StyledLine::new()];
460
461 let mut style_stack: Vec<Style> = vec![Style::default()];
463 let mut in_code_block = false;
464 let mut code_block_lang = String::new();
465 let mut current_link_url: Option<String> = None;
467
468 for event in parser {
469 match event {
470 Event::Start(tag) => {
471 match tag {
472 Tag::Strong => {
473 let current = *style_stack.last().unwrap_or(&Style::default());
474 style_stack.push(current.add_modifier(Modifier::BOLD));
475 }
476 Tag::Emphasis => {
477 let current = *style_stack.last().unwrap_or(&Style::default());
478 style_stack.push(current.add_modifier(Modifier::ITALIC));
479 }
480 Tag::Strikethrough => {
481 let current = *style_stack.last().unwrap_or(&Style::default());
482 style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
483 }
484 Tag::CodeBlock(kind) => {
485 in_code_block = true;
486 code_block_lang = match kind {
487 pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
488 pulldown_cmark::CodeBlockKind::Indented => String::new(),
489 };
490 if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
492 lines.push(StyledLine::new());
493 }
494 }
495 Tag::Heading { .. } => {
496 let current = *style_stack.last().unwrap_or(&Style::default());
497 style_stack
498 .push(current.add_modifier(Modifier::BOLD).fg(theme.help_key_fg));
499 }
500 Tag::Link { dest_url, .. } => {
501 let current = *style_stack.last().unwrap_or(&Style::default());
502 style_stack
503 .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
504 current_link_url = Some(dest_url.to_string());
506 }
507 Tag::Image { .. } => {
508 let current = *style_stack.last().unwrap_or(&Style::default());
509 style_stack
510 .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
511 }
512 Tag::List(_) | Tag::Item => {
513 if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
515 lines.push(StyledLine::new());
516 }
517 }
518 Tag::Paragraph => {
519 let has_prior_content = lines.iter().any(|l| !l.spans.is_empty());
522 if has_prior_content {
523 lines.push(StyledLine::new());
524 }
525 }
526 _ => {}
527 }
528 }
529 Event::End(tag_end) => {
530 match tag_end {
531 TagEnd::Strong
532 | TagEnd::Emphasis
533 | TagEnd::Strikethrough
534 | TagEnd::Heading(_)
535 | TagEnd::Image => {
536 style_stack.pop();
537 }
538 TagEnd::Link => {
539 style_stack.pop();
540 current_link_url = None;
542 }
543 TagEnd::CodeBlock => {
544 in_code_block = false;
545 code_block_lang.clear();
546 lines.push(StyledLine::new());
548 }
549 TagEnd::Paragraph => {
550 lines.push(StyledLine::new());
552 }
553 TagEnd::Item => {
554 }
556 _ => {}
557 }
558 }
559 Event::Text(text) => {
560 if in_code_block {
561 let spans = if let Some(reg) = registry {
563 if !code_block_lang.is_empty() {
564 let s = highlight_string(&text, &code_block_lang, reg, theme);
565 let highlighted_bytes: usize =
567 s.iter().map(|span| span.range.end - span.range.start).sum();
568 let non_ws_bytes =
569 text.bytes().filter(|b| !b.is_ascii_whitespace()).count();
570 let good_coverage =
571 non_ws_bytes == 0 || highlighted_bytes * 5 >= non_ws_bytes;
572 if good_coverage {
573 s
574 } else {
575 Vec::new()
576 }
577 } else {
578 Vec::new()
579 }
580 } else {
581 Vec::new()
582 };
583
584 if !spans.is_empty() {
585 let highlighted_lines =
586 highlight_code_to_styled_lines(&text, &spans, theme);
587 for (i, styled_line) in highlighted_lines.into_iter().enumerate() {
588 if i > 0 {
589 lines.push(StyledLine::new());
590 }
591 if let Some(current_line) = lines.last_mut() {
593 for span in styled_line.spans {
594 current_line.push(span.text, span.style);
595 }
596 }
597 }
598 } else {
599 let code_style = Style::default()
601 .fg(theme.help_key_fg)
602 .bg(theme.inline_code_bg);
603 add_text_to_lines(&mut lines, &text, code_style, None);
604 }
605 } else {
606 let current_style = *style_stack.last().unwrap_or(&Style::default());
607 add_text_to_lines(&mut lines, &text, current_style, current_link_url.clone());
608 }
609 }
610 Event::Code(code) => {
611 let style = Style::default()
613 .fg(theme.help_key_fg)
614 .bg(theme.inline_code_bg);
615 if let Some(line) = lines.last_mut() {
616 line.push(code.to_string(), style);
617 }
618 }
619 Event::SoftBreak => {
620 lines.push(StyledLine::new());
624 }
625 Event::HardBreak => {
626 lines.push(StyledLine::new());
628 }
629 Event::Rule => {
630 lines.push(StyledLine::new());
632 if let Some(line) = lines.last_mut() {
633 line.push("─".repeat(40), Style::default().fg(Color::DarkGray));
634 }
635 lines.push(StyledLine::new());
636 }
637 _ => {}
638 }
639 }
640
641 while lines.last().map(|l| l.spans.is_empty()).unwrap_or(false) {
643 lines.pop();
644 }
645
646 lines
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::view::theme;
653 use crate::view::theme::Theme;
654
655 fn has_modifier(line: &StyledLine, modifier: Modifier) -> bool {
656 line.spans
657 .iter()
658 .any(|s| s.style.add_modifier.contains(modifier))
659 }
660
661 #[test]
662 fn test_plain_text() {
663 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
664 let lines = parse_markdown("Hello world", &theme, None);
665
666 assert_eq!(lines.len(), 1);
667 assert_eq!(lines[0].plain_text(), "Hello world");
668 }
669
670 #[test]
671 fn test_bold_text() {
672 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
673 let lines = parse_markdown("This is **bold** text", &theme, None);
674
675 assert_eq!(lines.len(), 1);
676 assert_eq!(lines[0].plain_text(), "This is bold text");
677
678 let bold_span = lines[0].spans.iter().find(|s| s.text == "bold");
680 assert!(bold_span.is_some(), "Should have a 'bold' span");
681 assert!(
682 bold_span
683 .unwrap()
684 .style
685 .add_modifier
686 .contains(Modifier::BOLD),
687 "Bold span should have BOLD modifier"
688 );
689 }
690
691 #[test]
692 fn test_italic_text() {
693 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
694 let lines = parse_markdown("This is *italic* text", &theme, None);
695
696 assert_eq!(lines.len(), 1);
697 assert_eq!(lines[0].plain_text(), "This is italic text");
698
699 let italic_span = lines[0].spans.iter().find(|s| s.text == "italic");
700 assert!(italic_span.is_some(), "Should have an 'italic' span");
701 assert!(
702 italic_span
703 .unwrap()
704 .style
705 .add_modifier
706 .contains(Modifier::ITALIC),
707 "Italic span should have ITALIC modifier"
708 );
709 }
710
711 #[test]
712 fn test_strikethrough_text() {
713 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
714 let lines = parse_markdown("This is ~~deleted~~ text", &theme, None);
715
716 assert_eq!(lines.len(), 1);
717 assert_eq!(lines[0].plain_text(), "This is deleted text");
718
719 let strike_span = lines[0].spans.iter().find(|s| s.text == "deleted");
720 assert!(strike_span.is_some(), "Should have a 'deleted' span");
721 assert!(
722 strike_span
723 .unwrap()
724 .style
725 .add_modifier
726 .contains(Modifier::CROSSED_OUT),
727 "Strikethrough span should have CROSSED_OUT modifier"
728 );
729 }
730
731 #[test]
732 fn test_inline_code() {
733 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
734 let lines = parse_markdown("Use `println!` to print", &theme, None);
735
736 assert_eq!(lines.len(), 1);
737 assert_eq!(lines[0].plain_text(), "Use println! to print");
739
740 let code_span = lines[0].spans.iter().find(|s| s.text.contains("println"));
742 assert!(code_span.is_some(), "Should have a code span");
743 assert!(
744 code_span.unwrap().style.bg.is_some(),
745 "Inline code should have background color"
746 );
747 }
748
749 #[test]
750 fn test_code_block() {
751 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
752 let lines = parse_markdown("```rust\nfn main() {}\n```", &theme, None);
753
754 let code_line = lines.iter().find(|l| l.plain_text().contains("fn"));
756 assert!(code_line.is_some(), "Should have code block content");
757
758 let has_bg = code_line
761 .unwrap()
762 .spans
763 .iter()
764 .any(|s| s.style.bg.is_some());
765 assert!(has_bg, "Code block should have background color");
766 }
767
768 #[test]
769 fn test_code_block_syntax_highlighting() {
770 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
771 let registry =
772 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
773 let markdown = "```rust\nfn main() {\n println!(\"Hello\");\n}\n```";
775 let lines = parse_markdown(markdown, &theme, Some(®istry));
776
777 assert!(!lines.is_empty(), "Should have parsed lines");
779
780 let mut colors_used = std::collections::HashSet::new();
782 for line in &lines {
783 for span in &line.spans {
784 if let Some(fg) = span.style.fg {
785 colors_used.insert(format!("{:?}", fg));
786 }
787 }
788 }
789
790 assert!(
793 colors_used.len() > 1,
794 "Code block should have multiple colors for syntax highlighting, got: {:?}",
795 colors_used
796 );
797
798 let all_text: String = lines
800 .iter()
801 .map(|l| l.plain_text())
802 .collect::<Vec<_>>()
803 .join("");
804 assert!(all_text.contains("fn"), "Should contain 'fn' keyword");
805 assert!(all_text.contains("main"), "Should contain 'main'");
806 assert!(all_text.contains("println"), "Should contain 'println'");
807 }
808
809 #[test]
810 fn test_code_block_unknown_language_fallback() {
811 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
812 let markdown = "```unknownlang\nsome code here\n```";
814 let lines = parse_markdown(markdown, &theme, None);
815
816 assert!(!lines.is_empty(), "Should have parsed lines");
818
819 let all_text: String = lines
821 .iter()
822 .map(|l| l.plain_text())
823 .collect::<Vec<_>>()
824 .join("");
825 assert!(
826 all_text.contains("some code here"),
827 "Should contain the code"
828 );
829
830 let code_line = lines.iter().find(|l| l.plain_text().contains("some code"));
832 if let Some(line) = code_line {
833 for span in &line.spans {
834 assert!(span.style.bg.is_some(), "Code should have background color");
835 }
836 }
837 }
838
839 #[test]
840 fn test_heading() {
841 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
842 let lines = parse_markdown("# Heading\n\nContent", &theme, None);
843
844 let heading_line = &lines[0];
846 assert!(
847 has_modifier(heading_line, Modifier::BOLD),
848 "Heading should be bold"
849 );
850 assert_eq!(heading_line.plain_text(), "Heading");
851 }
852
853 #[test]
854 fn test_link() {
855 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
856 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
857
858 assert_eq!(lines.len(), 1);
859 assert_eq!(lines[0].plain_text(), "Click here for more");
860
861 let link_span = lines[0].spans.iter().find(|s| s.text == "here");
863 assert!(link_span.is_some(), "Should have 'here' span");
864 let style = link_span.unwrap().style;
865 assert!(
866 style.add_modifier.contains(Modifier::UNDERLINED),
867 "Link should be underlined"
868 );
869 assert_eq!(style.fg, Some(Color::Cyan), "Link should be cyan");
870 }
871
872 #[test]
873 fn test_link_url_stored() {
874 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
875 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
876
877 assert_eq!(lines.len(), 1);
878
879 let link_span = lines[0].spans.iter().find(|s| s.text == "here");
881 assert!(link_span.is_some(), "Should have 'here' span");
882 assert_eq!(
883 link_span.unwrap().link_url,
884 Some("https://example.com".to_string()),
885 "Link span should store the URL"
886 );
887
888 let click_span = lines[0].spans.iter().find(|s| s.text == "Click ");
890 assert!(click_span.is_some(), "Should have 'Click ' span");
891 assert_eq!(
892 click_span.unwrap().link_url,
893 None,
894 "Non-link span should not have URL"
895 );
896 }
897
898 #[test]
899 fn test_link_at_column() {
900 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
901 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
902
903 assert_eq!(lines.len(), 1);
904 let line = &lines[0];
905
906 assert_eq!(
909 line.link_at_column(0),
910 None,
911 "Column 0 should not be a link"
912 );
913 assert_eq!(
914 line.link_at_column(5),
915 None,
916 "Column 5 should not be a link"
917 );
918
919 assert_eq!(
921 line.link_at_column(6),
922 Some("https://example.com"),
923 "Column 6 should be the link"
924 );
925 assert_eq!(
926 line.link_at_column(9),
927 Some("https://example.com"),
928 "Column 9 should be the link"
929 );
930
931 assert_eq!(
933 line.link_at_column(10),
934 None,
935 "Column 10 should not be a link"
936 );
937 }
938
939 #[test]
940 fn test_unordered_list() {
941 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
942 let lines = parse_markdown("- Item 1\n- Item 2\n- Item 3", &theme, None);
943
944 assert!(lines.len() >= 3, "Should have at least 3 lines for 3 items");
946
947 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
948 assert!(all_text.contains("Item 1"), "Should contain Item 1");
949 assert!(all_text.contains("Item 2"), "Should contain Item 2");
950 assert!(all_text.contains("Item 3"), "Should contain Item 3");
951 }
952
953 #[test]
954 fn test_paragraph_separation() {
955 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
956 let lines = parse_markdown("First paragraph.\n\nSecond paragraph.", &theme, None);
957
958 assert_eq!(
960 lines.len(),
961 3,
962 "Should have 3 lines (para, blank, para), got: {:?}",
963 lines.iter().map(|l| l.plain_text()).collect::<Vec<_>>()
964 );
965
966 assert_eq!(lines[0].plain_text(), "First paragraph.");
967 assert!(
968 lines[1].spans.is_empty(),
969 "Second line should be empty (paragraph break)"
970 );
971 assert_eq!(lines[2].plain_text(), "Second paragraph.");
972 }
973
974 #[test]
975 fn test_soft_break_becomes_newline() {
976 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
977 let lines = parse_markdown("Line one\nLine two", &theme, None);
979
980 assert!(
982 lines.len() >= 2,
983 "Soft break should create separate lines, got {} lines",
984 lines.len()
985 );
986 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
987 assert!(
988 all_text.contains("one") && all_text.contains("two"),
989 "Should contain both lines"
990 );
991 }
992
993 #[test]
994 fn test_hard_break() {
995 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
996 let lines = parse_markdown("Line one \nLine two", &theme, None);
998
999 assert!(lines.len() >= 2, "Hard break should create multiple lines");
1001 }
1002
1003 #[test]
1004 fn test_horizontal_rule() {
1005 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1006 let lines = parse_markdown("Above\n\n---\n\nBelow", &theme, None);
1007
1008 let has_rule = lines.iter().any(|l| l.plain_text().contains("─"));
1010 assert!(has_rule, "Should contain horizontal rule character");
1011 }
1012
1013 #[test]
1014 fn test_nested_formatting() {
1015 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1016 let lines = parse_markdown("This is ***bold and italic*** text", &theme, None);
1017
1018 assert_eq!(lines.len(), 1);
1019
1020 let nested_span = lines[0].spans.iter().find(|s| s.text == "bold and italic");
1022 assert!(nested_span.is_some(), "Should have nested formatted span");
1023
1024 let style = nested_span.unwrap().style;
1025 assert!(
1026 style.add_modifier.contains(Modifier::BOLD),
1027 "Should be bold"
1028 );
1029 assert!(
1030 style.add_modifier.contains(Modifier::ITALIC),
1031 "Should be italic"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_lsp_hover_docstring() {
1037 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1039 let markdown = "```python\n(class) Path\n```\n\nPurePath subclass that can make system calls.\n\nPath represents a filesystem path.";
1040
1041 let lines = parse_markdown(markdown, &theme, None);
1042
1043 assert!(lines.len() >= 3, "Should have multiple sections");
1045
1046 let code_line = lines.iter().find(|l| l.plain_text().contains("Path"));
1048 assert!(code_line.is_some(), "Should have code block with Path");
1049
1050 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
1052 assert!(
1053 all_text.contains("PurePath subclass"),
1054 "Should contain docstring"
1055 );
1056 }
1057
1058 #[test]
1059 fn test_python_docstring_formatting() {
1060 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1062 let markdown = "Keyword Arguments:\n - prog -- The name\n - usage -- A usage message";
1063 let lines = parse_markdown(markdown, &theme, None);
1064
1065 assert!(
1067 lines.len() >= 3,
1068 "Should have multiple lines for keyword args list, got {} lines: {:?}",
1069 lines.len(),
1070 lines.iter().map(|l| l.plain_text()).collect::<Vec<_>>()
1071 );
1072 }
1073
1074 #[test]
1075 fn test_empty_input() {
1076 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1077 let lines = parse_markdown("", &theme, None);
1078
1079 assert!(
1081 lines.is_empty() || (lines.len() == 1 && lines[0].spans.is_empty()),
1082 "Empty input should produce empty output"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_only_whitespace() {
1088 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1089 let lines = parse_markdown(" \n\n ", &theme, None);
1090
1091 for line in &lines {
1093 let text = line.plain_text();
1094 assert!(
1095 text.trim().is_empty(),
1096 "Whitespace-only input should not produce content"
1097 );
1098 }
1099 }
1100
1101 #[test]
1104 fn test_wrap_text_line_at_word_boundaries() {
1105 let text = "Path represents a filesystem path but unlike PurePath also offers methods";
1107 let wrapped = wrap_text_line(text, 30);
1108
1109 for (i, line) in wrapped.iter().enumerate() {
1111 if !line.is_empty() {
1113 assert!(
1114 !line.starts_with(' '),
1115 "Line {} should not start with space: {:?}",
1116 i,
1117 line
1118 );
1119 }
1120
1121 let line_width = unicode_width::UnicodeWidthStr::width(line.as_str());
1123 assert!(
1124 line_width <= 30,
1125 "Line {} exceeds max width: {} > 30, content: {:?}",
1126 i,
1127 line_width,
1128 line
1129 );
1130 }
1131
1132 let original_words: Vec<&str> = text.split_whitespace().collect();
1135 let wrapped_words: Vec<&str> = wrapped
1136 .iter()
1137 .flat_map(|line| line.split_whitespace())
1138 .collect();
1139 assert_eq!(
1140 original_words, wrapped_words,
1141 "Words should be preserved without breaking mid-word"
1142 );
1143
1144 assert_eq!(
1146 wrapped[0], "Path represents a filesystem",
1147 "First line should break at word boundary"
1148 );
1149 assert_eq!(
1150 wrapped[1], "path but unlike PurePath also",
1151 "Second line should contain next words (30 chars fits)"
1152 );
1153 assert_eq!(
1154 wrapped[2], "offers methods",
1155 "Third line should contain remaining words"
1156 );
1157 }
1158
1159 #[test]
1160 fn test_wrap_text_line_long_word() {
1161 let text = "supercalifragilisticexpialidocious";
1163 let wrapped = wrap_text_line(text, 10);
1164
1165 assert!(
1166 wrapped.len() > 1,
1167 "Long word should be split into multiple lines"
1168 );
1169
1170 for line in &wrapped {
1172 let width = unicode_width::UnicodeWidthStr::width(line.as_str());
1173 assert!(width <= 10, "Line should not exceed max width: {}", line);
1174 }
1175
1176 let rejoined: String = wrapped.join("");
1178 assert_eq!(rejoined, text, "Content should be preserved");
1179 }
1180
1181 #[test]
1182 fn test_wrap_text_line_empty() {
1183 let wrapped = wrap_text_line("", 30);
1184 assert_eq!(wrapped.len(), 1);
1185 assert_eq!(wrapped[0], "");
1186 }
1187
1188 #[test]
1189 fn test_wrap_text_line_fits() {
1190 let text = "Short text";
1191 let wrapped = wrap_text_line(text, 30);
1192 assert_eq!(wrapped.len(), 1);
1193 assert_eq!(wrapped[0], text);
1194 }
1195
1196 #[test]
1197 fn test_wrap_styled_lines_long_hover_content() {
1198 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1200
1201 let long_text = "def very_long_function_name(param1: str, param2: int, param3: float, param4: list, param5: dict) -> tuple[str, int, float]";
1203 let markdown = format!("```python\n{}\n```", long_text);
1204
1205 let lines = parse_markdown(&markdown, &theme, None);
1206
1207 assert!(!lines.is_empty(), "Should have parsed lines");
1209
1210 let wrapped = wrap_styled_lines(&lines, 40);
1212
1213 assert!(
1215 wrapped.len() > lines.len(),
1216 "Long line should wrap into multiple lines. Original: {}, Wrapped: {}",
1217 lines.len(),
1218 wrapped.len()
1219 );
1220
1221 for (i, line) in wrapped.iter().enumerate() {
1223 let line_width: usize = line
1224 .spans
1225 .iter()
1226 .map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
1227 .sum();
1228 assert!(
1229 line_width <= 40,
1230 "Wrapped line {} exceeds max width: {} > 40, content: {:?}",
1231 i,
1232 line_width,
1233 line.spans
1234 .iter()
1235 .map(|s| s.text.as_str())
1236 .collect::<Vec<_>>()
1237 );
1238 }
1239
1240 let original_text: String = lines
1242 .iter()
1243 .flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
1244 .collect();
1245 let wrapped_text: String = wrapped
1246 .iter()
1247 .map(|l| l.spans.iter().map(|s| s.text.as_str()).collect::<String>())
1248 .collect::<Vec<_>>()
1249 .join(" ");
1250 assert_eq!(
1251 original_text, wrapped_text,
1252 "Content should be preserved after wrapping (with spaces at line joins)"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_wrap_styled_lines_preserves_style() {
1258 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1259 let lines = parse_markdown("**bold text that is quite long**", &theme, None);
1260
1261 let wrapped = wrap_styled_lines(&lines, 15);
1262
1263 for line in &wrapped {
1265 for span in &line.spans {
1266 if !span.text.trim().is_empty() {
1267 assert!(
1268 span.style.add_modifier.contains(Modifier::BOLD),
1269 "Style should be preserved after wrapping: {:?}",
1270 span.text
1271 );
1272 }
1273 }
1274 }
1275 }
1276
1277 #[test]
1278 fn test_wrap_text_lines_multiple() {
1279 let lines = vec![
1280 "Short".to_string(),
1281 "This is a longer line that needs wrapping".to_string(),
1282 "".to_string(),
1283 "Another line".to_string(),
1284 ];
1285
1286 let wrapped = wrap_text_lines(&lines, 20);
1287
1288 assert!(
1290 wrapped.iter().any(|l| l.is_empty()),
1291 "Should preserve empty lines"
1292 );
1293
1294 for line in &wrapped {
1296 let width = unicode_width::UnicodeWidthStr::width(line.as_str());
1297 assert!(width <= 20, "Line exceeds max width: {}", line);
1298 }
1299 }
1300
1301 #[test]
1302 fn test_signature_help_doc_indent_preserved() {
1303 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1306 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.";
1307
1308 let lines = parse_markdown(content, &theme, None);
1309 let texts: Vec<String> = lines.iter().map(|l| l.plain_text()).collect();
1310 eprintln!("[TEST] Parsed markdown lines:");
1311 for (i, t) in texts.iter().enumerate() {
1312 eprintln!(" [{}] {:?}", i, t);
1313 }
1314
1315 let desc_line = texts
1317 .iter()
1318 .find(|t| t.contains("string appended"))
1319 .expect("Should find 'string appended' line");
1320 eprintln!("[TEST] desc_line: {:?}", desc_line);
1321
1322 let wrapped = wrap_styled_lines(&lines, 40);
1324 let wrapped_texts: Vec<String> = wrapped.iter().map(|l| l.plain_text()).collect();
1325 eprintln!("[TEST] Wrapped lines:");
1326 for (i, t) in wrapped_texts.iter().enumerate() {
1327 eprintln!(" [{}] {:?}", i, t);
1328 }
1329
1330 let desc_idx = wrapped_texts
1332 .iter()
1333 .position(|t| t.contains("string appended"))
1334 .expect("Should find 'string appended' line in wrapped output");
1335 assert!(
1336 desc_idx + 1 < wrapped_texts.len(),
1337 "Line should have wrapped, but didn't. Lines: {:?}",
1338 wrapped_texts
1339 );
1340 let continuation = &wrapped_texts[desc_idx + 1];
1341 eprintln!("[TEST] continuation: {:?}", continuation);
1342
1343 let orig_indent = count_leading_spaces(desc_line);
1345 let cont_indent = count_leading_spaces(continuation);
1346 eprintln!(
1347 "[TEST] orig_indent={}, cont_indent={}",
1348 orig_indent, cont_indent
1349 );
1350 assert_eq!(
1351 cont_indent, orig_indent,
1352 "Continuation line should have same indent as original"
1353 );
1354 }
1355}