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.help_key_fg;
359
360 let bytes = code.as_bytes();
361 let mut pos = 0;
362
363 for span in spans {
364 if span.range.start > pos {
366 let text = String::from_utf8_lossy(&bytes[pos..span.range.start]);
367 add_text_to_lines(
368 &mut result,
369 &text,
370 Style::default().fg(default_fg).bg(code_bg),
371 None,
372 );
373 }
374
375 let text = String::from_utf8_lossy(&bytes[span.range.start..span.range.end]);
377 add_text_to_lines(
378 &mut result,
379 &text,
380 Style::default().fg(span.color).bg(code_bg),
381 None,
382 );
383
384 pos = span.range.end;
385 }
386
387 if pos < bytes.len() {
389 let text = String::from_utf8_lossy(&bytes[pos..]);
390 add_text_to_lines(
391 &mut result,
392 &text,
393 Style::default().fg(default_fg).bg(code_bg),
394 None,
395 );
396 }
397
398 result
399}
400
401fn add_text_to_lines(
405 lines: &mut Vec<StyledLine>,
406 text: &str,
407 style: Style,
408 link_url: Option<String>,
409) {
410 for (i, part) in text.split('\n').enumerate() {
411 if i > 0 {
412 lines.push(StyledLine::new());
413 }
414 if !part.is_empty() {
415 if let Some(line) = lines.last_mut() {
416 line.push_with_link(part.to_string(), style, link_url.clone());
417 }
418 }
419 }
420}
421
422fn preserve_leading_whitespace(text: &str) -> String {
427 text.lines()
428 .map(|line| {
429 let indent = line.len() - line.trim_start_matches(' ').len();
430 if indent > 0 {
431 format!("{}{}", "\u{00A0}".repeat(indent), &line[indent..])
432 } else {
433 line.to_string()
434 }
435 })
436 .collect::<Vec<_>>()
437 .join("\n")
438}
439
440pub fn parse_markdown(
445 text: &str,
446 theme: &crate::view::theme::Theme,
447 registry: Option<&GrammarRegistry>,
448) -> Vec<StyledLine> {
449 let preserved = preserve_leading_whitespace(text);
452
453 let mut options = Options::empty();
454 options.insert(Options::ENABLE_STRIKETHROUGH);
455
456 let parser = Parser::new_ext(&preserved, options);
457 let mut lines: Vec<StyledLine> = vec![StyledLine::new()];
458
459 let mut style_stack: Vec<Style> = vec![Style::default()];
461 let mut in_code_block = false;
462 let mut code_block_lang = String::new();
463 let mut current_link_url: Option<String> = None;
465
466 for event in parser {
467 match event {
468 Event::Start(tag) => {
469 match tag {
470 Tag::Strong => {
471 let current = *style_stack.last().unwrap_or(&Style::default());
472 style_stack.push(current.add_modifier(Modifier::BOLD));
473 }
474 Tag::Emphasis => {
475 let current = *style_stack.last().unwrap_or(&Style::default());
476 style_stack.push(current.add_modifier(Modifier::ITALIC));
477 }
478 Tag::Strikethrough => {
479 let current = *style_stack.last().unwrap_or(&Style::default());
480 style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
481 }
482 Tag::CodeBlock(kind) => {
483 in_code_block = true;
484 code_block_lang = match kind {
485 pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
486 pulldown_cmark::CodeBlockKind::Indented => String::new(),
487 };
488 if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
490 lines.push(StyledLine::new());
491 }
492 }
493 Tag::Heading { .. } => {
494 let current = *style_stack.last().unwrap_or(&Style::default());
495 style_stack
496 .push(current.add_modifier(Modifier::BOLD).fg(theme.help_key_fg));
497 }
498 Tag::Link { dest_url, .. } => {
499 let current = *style_stack.last().unwrap_or(&Style::default());
500 style_stack
501 .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
502 current_link_url = Some(dest_url.to_string());
504 }
505 Tag::Image { .. } => {
506 let current = *style_stack.last().unwrap_or(&Style::default());
507 style_stack
508 .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
509 }
510 Tag::List(_) | Tag::Item => {
511 if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
513 lines.push(StyledLine::new());
514 }
515 }
516 Tag::Paragraph => {
517 let has_prior_content = lines.iter().any(|l| !l.spans.is_empty());
520 if has_prior_content {
521 lines.push(StyledLine::new());
522 }
523 }
524 _ => {}
525 }
526 }
527 Event::End(tag_end) => {
528 match tag_end {
529 TagEnd::Strong
530 | TagEnd::Emphasis
531 | TagEnd::Strikethrough
532 | TagEnd::Heading(_)
533 | TagEnd::Image => {
534 style_stack.pop();
535 }
536 TagEnd::Link => {
537 style_stack.pop();
538 current_link_url = None;
540 }
541 TagEnd::CodeBlock => {
542 in_code_block = false;
543 code_block_lang.clear();
544 lines.push(StyledLine::new());
546 }
547 TagEnd::Paragraph => {
548 lines.push(StyledLine::new());
550 }
551 TagEnd::Item => {
552 }
554 _ => {}
555 }
556 }
557 Event::Text(text) => {
558 if in_code_block {
559 let spans = if let Some(reg) = registry {
561 if !code_block_lang.is_empty() {
562 let s = highlight_string(&text, &code_block_lang, reg, theme);
563 let highlighted_bytes: usize =
565 s.iter().map(|span| span.range.end - span.range.start).sum();
566 let non_ws_bytes =
567 text.bytes().filter(|b| !b.is_ascii_whitespace()).count();
568 let good_coverage =
569 non_ws_bytes == 0 || highlighted_bytes * 5 >= non_ws_bytes;
570 if good_coverage {
571 s
572 } else {
573 Vec::new()
574 }
575 } else {
576 Vec::new()
577 }
578 } else {
579 Vec::new()
580 };
581
582 if !spans.is_empty() {
583 let highlighted_lines =
584 highlight_code_to_styled_lines(&text, &spans, theme);
585 for (i, styled_line) in highlighted_lines.into_iter().enumerate() {
586 if i > 0 {
587 lines.push(StyledLine::new());
588 }
589 if let Some(current_line) = lines.last_mut() {
591 for span in styled_line.spans {
592 current_line.push(span.text, span.style);
593 }
594 }
595 }
596 } else {
597 let code_style = Style::default()
599 .fg(theme.help_key_fg)
600 .bg(theme.inline_code_bg);
601 add_text_to_lines(&mut lines, &text, code_style, None);
602 }
603 } else {
604 let current_style = *style_stack.last().unwrap_or(&Style::default());
605 add_text_to_lines(&mut lines, &text, current_style, current_link_url.clone());
606 }
607 }
608 Event::Code(code) => {
609 let style = Style::default()
611 .fg(theme.help_key_fg)
612 .bg(theme.inline_code_bg);
613 if let Some(line) = lines.last_mut() {
614 line.push(code.to_string(), style);
615 }
616 }
617 Event::SoftBreak => {
618 lines.push(StyledLine::new());
622 }
623 Event::HardBreak => {
624 lines.push(StyledLine::new());
626 }
627 Event::Rule => {
628 lines.push(StyledLine::new());
630 if let Some(line) = lines.last_mut() {
631 line.push("─".repeat(40), Style::default().fg(Color::DarkGray));
632 }
633 lines.push(StyledLine::new());
634 }
635 _ => {}
636 }
637 }
638
639 while lines.last().map(|l| l.spans.is_empty()).unwrap_or(false) {
641 lines.pop();
642 }
643
644 lines
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use crate::view::theme;
651 use crate::view::theme::Theme;
652
653 fn has_modifier(line: &StyledLine, modifier: Modifier) -> bool {
654 line.spans
655 .iter()
656 .any(|s| s.style.add_modifier.contains(modifier))
657 }
658
659 #[test]
660 fn test_plain_text() {
661 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
662 let lines = parse_markdown("Hello world", &theme, None);
663
664 assert_eq!(lines.len(), 1);
665 assert_eq!(lines[0].plain_text(), "Hello world");
666 }
667
668 #[test]
669 fn test_bold_text() {
670 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
671 let lines = parse_markdown("This is **bold** text", &theme, None);
672
673 assert_eq!(lines.len(), 1);
674 assert_eq!(lines[0].plain_text(), "This is bold text");
675
676 let bold_span = lines[0].spans.iter().find(|s| s.text == "bold");
678 assert!(bold_span.is_some(), "Should have a 'bold' span");
679 assert!(
680 bold_span
681 .unwrap()
682 .style
683 .add_modifier
684 .contains(Modifier::BOLD),
685 "Bold span should have BOLD modifier"
686 );
687 }
688
689 #[test]
690 fn test_italic_text() {
691 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
692 let lines = parse_markdown("This is *italic* text", &theme, None);
693
694 assert_eq!(lines.len(), 1);
695 assert_eq!(lines[0].plain_text(), "This is italic text");
696
697 let italic_span = lines[0].spans.iter().find(|s| s.text == "italic");
698 assert!(italic_span.is_some(), "Should have an 'italic' span");
699 assert!(
700 italic_span
701 .unwrap()
702 .style
703 .add_modifier
704 .contains(Modifier::ITALIC),
705 "Italic span should have ITALIC modifier"
706 );
707 }
708
709 #[test]
710 fn test_strikethrough_text() {
711 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
712 let lines = parse_markdown("This is ~~deleted~~ text", &theme, None);
713
714 assert_eq!(lines.len(), 1);
715 assert_eq!(lines[0].plain_text(), "This is deleted text");
716
717 let strike_span = lines[0].spans.iter().find(|s| s.text == "deleted");
718 assert!(strike_span.is_some(), "Should have a 'deleted' span");
719 assert!(
720 strike_span
721 .unwrap()
722 .style
723 .add_modifier
724 .contains(Modifier::CROSSED_OUT),
725 "Strikethrough span should have CROSSED_OUT modifier"
726 );
727 }
728
729 #[test]
730 fn test_inline_code() {
731 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
732 let lines = parse_markdown("Use `println!` to print", &theme, None);
733
734 assert_eq!(lines.len(), 1);
735 assert_eq!(lines[0].plain_text(), "Use println! to print");
737
738 let code_span = lines[0].spans.iter().find(|s| s.text.contains("println"));
740 assert!(code_span.is_some(), "Should have a code span");
741 assert!(
742 code_span.unwrap().style.bg.is_some(),
743 "Inline code should have background color"
744 );
745 }
746
747 #[test]
748 fn test_code_block() {
749 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
750 let lines = parse_markdown("```rust\nfn main() {}\n```", &theme, None);
751
752 let code_line = lines.iter().find(|l| l.plain_text().contains("fn"));
754 assert!(code_line.is_some(), "Should have code block content");
755
756 let has_bg = code_line
759 .unwrap()
760 .spans
761 .iter()
762 .any(|s| s.style.bg.is_some());
763 assert!(has_bg, "Code block should have background color");
764 }
765
766 #[test]
767 fn test_code_block_syntax_highlighting() {
768 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
769 let registry =
770 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
771 let markdown = "```rust\nfn main() {\n println!(\"Hello\");\n}\n```";
773 let lines = parse_markdown(markdown, &theme, Some(®istry));
774
775 assert!(!lines.is_empty(), "Should have parsed lines");
777
778 let mut colors_used = std::collections::HashSet::new();
780 for line in &lines {
781 for span in &line.spans {
782 if let Some(fg) = span.style.fg {
783 colors_used.insert(format!("{:?}", fg));
784 }
785 }
786 }
787
788 assert!(
791 colors_used.len() > 1,
792 "Code block should have multiple colors for syntax highlighting, got: {:?}",
793 colors_used
794 );
795
796 let all_text: String = lines
798 .iter()
799 .map(|l| l.plain_text())
800 .collect::<Vec<_>>()
801 .join("");
802 assert!(all_text.contains("fn"), "Should contain 'fn' keyword");
803 assert!(all_text.contains("main"), "Should contain 'main'");
804 assert!(all_text.contains("println"), "Should contain 'println'");
805 }
806
807 #[test]
808 fn test_code_block_unknown_language_fallback() {
809 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
810 let markdown = "```unknownlang\nsome code here\n```";
812 let lines = parse_markdown(markdown, &theme, None);
813
814 assert!(!lines.is_empty(), "Should have parsed lines");
816
817 let all_text: String = lines
819 .iter()
820 .map(|l| l.plain_text())
821 .collect::<Vec<_>>()
822 .join("");
823 assert!(
824 all_text.contains("some code here"),
825 "Should contain the code"
826 );
827
828 let code_line = lines.iter().find(|l| l.plain_text().contains("some code"));
830 if let Some(line) = code_line {
831 for span in &line.spans {
832 assert!(span.style.bg.is_some(), "Code should have background color");
833 }
834 }
835 }
836
837 #[test]
838 fn test_heading() {
839 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
840 let lines = parse_markdown("# Heading\n\nContent", &theme, None);
841
842 let heading_line = &lines[0];
844 assert!(
845 has_modifier(heading_line, Modifier::BOLD),
846 "Heading should be bold"
847 );
848 assert_eq!(heading_line.plain_text(), "Heading");
849 }
850
851 #[test]
852 fn test_link() {
853 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
854 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
855
856 assert_eq!(lines.len(), 1);
857 assert_eq!(lines[0].plain_text(), "Click here for more");
858
859 let link_span = lines[0].spans.iter().find(|s| s.text == "here");
861 assert!(link_span.is_some(), "Should have 'here' span");
862 let style = link_span.unwrap().style;
863 assert!(
864 style.add_modifier.contains(Modifier::UNDERLINED),
865 "Link should be underlined"
866 );
867 assert_eq!(style.fg, Some(Color::Cyan), "Link should be cyan");
868 }
869
870 #[test]
871 fn test_link_url_stored() {
872 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
873 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
874
875 assert_eq!(lines.len(), 1);
876
877 let link_span = lines[0].spans.iter().find(|s| s.text == "here");
879 assert!(link_span.is_some(), "Should have 'here' span");
880 assert_eq!(
881 link_span.unwrap().link_url,
882 Some("https://example.com".to_string()),
883 "Link span should store the URL"
884 );
885
886 let click_span = lines[0].spans.iter().find(|s| s.text == "Click ");
888 assert!(click_span.is_some(), "Should have 'Click ' span");
889 assert_eq!(
890 click_span.unwrap().link_url,
891 None,
892 "Non-link span should not have URL"
893 );
894 }
895
896 #[test]
897 fn test_link_at_column() {
898 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
899 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
900
901 assert_eq!(lines.len(), 1);
902 let line = &lines[0];
903
904 assert_eq!(
907 line.link_at_column(0),
908 None,
909 "Column 0 should not be a link"
910 );
911 assert_eq!(
912 line.link_at_column(5),
913 None,
914 "Column 5 should not be a link"
915 );
916
917 assert_eq!(
919 line.link_at_column(6),
920 Some("https://example.com"),
921 "Column 6 should be the link"
922 );
923 assert_eq!(
924 line.link_at_column(9),
925 Some("https://example.com"),
926 "Column 9 should be the link"
927 );
928
929 assert_eq!(
931 line.link_at_column(10),
932 None,
933 "Column 10 should not be a link"
934 );
935 }
936
937 #[test]
938 fn test_unordered_list() {
939 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
940 let lines = parse_markdown("- Item 1\n- Item 2\n- Item 3", &theme, None);
941
942 assert!(lines.len() >= 3, "Should have at least 3 lines for 3 items");
944
945 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
946 assert!(all_text.contains("Item 1"), "Should contain Item 1");
947 assert!(all_text.contains("Item 2"), "Should contain Item 2");
948 assert!(all_text.contains("Item 3"), "Should contain Item 3");
949 }
950
951 #[test]
952 fn test_paragraph_separation() {
953 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
954 let lines = parse_markdown("First paragraph.\n\nSecond paragraph.", &theme, None);
955
956 assert_eq!(
958 lines.len(),
959 3,
960 "Should have 3 lines (para, blank, para), got: {:?}",
961 lines.iter().map(|l| l.plain_text()).collect::<Vec<_>>()
962 );
963
964 assert_eq!(lines[0].plain_text(), "First paragraph.");
965 assert!(
966 lines[1].spans.is_empty(),
967 "Second line should be empty (paragraph break)"
968 );
969 assert_eq!(lines[2].plain_text(), "Second paragraph.");
970 }
971
972 #[test]
973 fn test_soft_break_becomes_newline() {
974 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
975 let lines = parse_markdown("Line one\nLine two", &theme, None);
977
978 assert!(
980 lines.len() >= 2,
981 "Soft break should create separate lines, got {} lines",
982 lines.len()
983 );
984 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
985 assert!(
986 all_text.contains("one") && all_text.contains("two"),
987 "Should contain both lines"
988 );
989 }
990
991 #[test]
992 fn test_hard_break() {
993 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
994 let lines = parse_markdown("Line one \nLine two", &theme, None);
996
997 assert!(lines.len() >= 2, "Hard break should create multiple lines");
999 }
1000
1001 #[test]
1002 fn test_horizontal_rule() {
1003 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1004 let lines = parse_markdown("Above\n\n---\n\nBelow", &theme, None);
1005
1006 let has_rule = lines.iter().any(|l| l.plain_text().contains("─"));
1008 assert!(has_rule, "Should contain horizontal rule character");
1009 }
1010
1011 #[test]
1012 fn test_nested_formatting() {
1013 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1014 let lines = parse_markdown("This is ***bold and italic*** text", &theme, None);
1015
1016 assert_eq!(lines.len(), 1);
1017
1018 let nested_span = lines[0].spans.iter().find(|s| s.text == "bold and italic");
1020 assert!(nested_span.is_some(), "Should have nested formatted span");
1021
1022 let style = nested_span.unwrap().style;
1023 assert!(
1024 style.add_modifier.contains(Modifier::BOLD),
1025 "Should be bold"
1026 );
1027 assert!(
1028 style.add_modifier.contains(Modifier::ITALIC),
1029 "Should be italic"
1030 );
1031 }
1032
1033 #[test]
1034 fn test_lsp_hover_docstring() {
1035 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1037 let markdown = "```python\n(class) Path\n```\n\nPurePath subclass that can make system calls.\n\nPath represents a filesystem path.";
1038
1039 let lines = parse_markdown(markdown, &theme, None);
1040
1041 assert!(lines.len() >= 3, "Should have multiple sections");
1043
1044 let code_line = lines.iter().find(|l| l.plain_text().contains("Path"));
1046 assert!(code_line.is_some(), "Should have code block with Path");
1047
1048 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
1050 assert!(
1051 all_text.contains("PurePath subclass"),
1052 "Should contain docstring"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_python_docstring_formatting() {
1058 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1060 let markdown = "Keyword Arguments:\n - prog -- The name\n - usage -- A usage message";
1061 let lines = parse_markdown(markdown, &theme, None);
1062
1063 assert!(
1065 lines.len() >= 3,
1066 "Should have multiple lines for keyword args list, got {} lines: {:?}",
1067 lines.len(),
1068 lines.iter().map(|l| l.plain_text()).collect::<Vec<_>>()
1069 );
1070 }
1071
1072 #[test]
1073 fn test_empty_input() {
1074 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1075 let lines = parse_markdown("", &theme, None);
1076
1077 assert!(
1079 lines.is_empty() || (lines.len() == 1 && lines[0].spans.is_empty()),
1080 "Empty input should produce empty output"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_only_whitespace() {
1086 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1087 let lines = parse_markdown(" \n\n ", &theme, None);
1088
1089 for line in &lines {
1091 let text = line.plain_text();
1092 assert!(
1093 text.trim().is_empty(),
1094 "Whitespace-only input should not produce content"
1095 );
1096 }
1097 }
1098
1099 #[test]
1102 fn test_wrap_text_line_at_word_boundaries() {
1103 let text = "Path represents a filesystem path but unlike PurePath also offers methods";
1105 let wrapped = wrap_text_line(text, 30);
1106
1107 for (i, line) in wrapped.iter().enumerate() {
1109 if !line.is_empty() {
1111 assert!(
1112 !line.starts_with(' '),
1113 "Line {} should not start with space: {:?}",
1114 i,
1115 line
1116 );
1117 }
1118
1119 let line_width = unicode_width::UnicodeWidthStr::width(line.as_str());
1121 assert!(
1122 line_width <= 30,
1123 "Line {} exceeds max width: {} > 30, content: {:?}",
1124 i,
1125 line_width,
1126 line
1127 );
1128 }
1129
1130 let original_words: Vec<&str> = text.split_whitespace().collect();
1133 let wrapped_words: Vec<&str> = wrapped
1134 .iter()
1135 .flat_map(|line| line.split_whitespace())
1136 .collect();
1137 assert_eq!(
1138 original_words, wrapped_words,
1139 "Words should be preserved without breaking mid-word"
1140 );
1141
1142 assert_eq!(
1144 wrapped[0], "Path represents a filesystem",
1145 "First line should break at word boundary"
1146 );
1147 assert_eq!(
1148 wrapped[1], "path but unlike PurePath also",
1149 "Second line should contain next words (30 chars fits)"
1150 );
1151 assert_eq!(
1152 wrapped[2], "offers methods",
1153 "Third line should contain remaining words"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_wrap_text_line_long_word() {
1159 let text = "supercalifragilisticexpialidocious";
1161 let wrapped = wrap_text_line(text, 10);
1162
1163 assert!(
1164 wrapped.len() > 1,
1165 "Long word should be split into multiple lines"
1166 );
1167
1168 for line in &wrapped {
1170 let width = unicode_width::UnicodeWidthStr::width(line.as_str());
1171 assert!(width <= 10, "Line should not exceed max width: {}", line);
1172 }
1173
1174 let rejoined: String = wrapped.join("");
1176 assert_eq!(rejoined, text, "Content should be preserved");
1177 }
1178
1179 #[test]
1180 fn test_wrap_text_line_empty() {
1181 let wrapped = wrap_text_line("", 30);
1182 assert_eq!(wrapped.len(), 1);
1183 assert_eq!(wrapped[0], "");
1184 }
1185
1186 #[test]
1187 fn test_wrap_text_line_fits() {
1188 let text = "Short text";
1189 let wrapped = wrap_text_line(text, 30);
1190 assert_eq!(wrapped.len(), 1);
1191 assert_eq!(wrapped[0], text);
1192 }
1193
1194 #[test]
1195 fn test_wrap_styled_lines_long_hover_content() {
1196 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1198
1199 let long_text = "def very_long_function_name(param1: str, param2: int, param3: float, param4: list, param5: dict) -> tuple[str, int, float]";
1201 let markdown = format!("```python\n{}\n```", long_text);
1202
1203 let lines = parse_markdown(&markdown, &theme, None);
1204
1205 assert!(!lines.is_empty(), "Should have parsed lines");
1207
1208 let wrapped = wrap_styled_lines(&lines, 40);
1210
1211 assert!(
1213 wrapped.len() > lines.len(),
1214 "Long line should wrap into multiple lines. Original: {}, Wrapped: {}",
1215 lines.len(),
1216 wrapped.len()
1217 );
1218
1219 for (i, line) in wrapped.iter().enumerate() {
1221 let line_width: usize = line
1222 .spans
1223 .iter()
1224 .map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
1225 .sum();
1226 assert!(
1227 line_width <= 40,
1228 "Wrapped line {} exceeds max width: {} > 40, content: {:?}",
1229 i,
1230 line_width,
1231 line.spans
1232 .iter()
1233 .map(|s| s.text.as_str())
1234 .collect::<Vec<_>>()
1235 );
1236 }
1237
1238 let original_text: String = lines
1240 .iter()
1241 .flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
1242 .collect();
1243 let wrapped_text: String = wrapped
1244 .iter()
1245 .map(|l| l.spans.iter().map(|s| s.text.as_str()).collect::<String>())
1246 .collect::<Vec<_>>()
1247 .join(" ");
1248 assert_eq!(
1249 original_text, wrapped_text,
1250 "Content should be preserved after wrapping (with spaces at line joins)"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_wrap_styled_lines_preserves_style() {
1256 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1257 let lines = parse_markdown("**bold text that is quite long**", &theme, None);
1258
1259 let wrapped = wrap_styled_lines(&lines, 15);
1260
1261 for line in &wrapped {
1263 for span in &line.spans {
1264 if !span.text.trim().is_empty() {
1265 assert!(
1266 span.style.add_modifier.contains(Modifier::BOLD),
1267 "Style should be preserved after wrapping: {:?}",
1268 span.text
1269 );
1270 }
1271 }
1272 }
1273 }
1274
1275 #[test]
1276 fn test_wrap_text_lines_multiple() {
1277 let lines = vec![
1278 "Short".to_string(),
1279 "This is a longer line that needs wrapping".to_string(),
1280 "".to_string(),
1281 "Another line".to_string(),
1282 ];
1283
1284 let wrapped = wrap_text_lines(&lines, 20);
1285
1286 assert!(
1288 wrapped.iter().any(|l| l.is_empty()),
1289 "Should preserve empty lines"
1290 );
1291
1292 for line in &wrapped {
1294 let width = unicode_width::UnicodeWidthStr::width(line.as_str());
1295 assert!(width <= 20, "Line exceeds max width: {}", line);
1296 }
1297 }
1298
1299 #[test]
1300 fn test_signature_help_doc_indent_preserved() {
1301 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1304 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.";
1305
1306 let lines = parse_markdown(content, &theme, None);
1307 let texts: Vec<String> = lines.iter().map(|l| l.plain_text()).collect();
1308 eprintln!("[TEST] Parsed markdown lines:");
1309 for (i, t) in texts.iter().enumerate() {
1310 eprintln!(" [{}] {:?}", i, t);
1311 }
1312
1313 let desc_line = texts
1315 .iter()
1316 .find(|t| t.contains("string appended"))
1317 .expect("Should find 'string appended' line");
1318 eprintln!("[TEST] desc_line: {:?}", desc_line);
1319
1320 let wrapped = wrap_styled_lines(&lines, 40);
1322 let wrapped_texts: Vec<String> = wrapped.iter().map(|l| l.plain_text()).collect();
1323 eprintln!("[TEST] Wrapped lines:");
1324 for (i, t) in wrapped_texts.iter().enumerate() {
1325 eprintln!(" [{}] {:?}", i, t);
1326 }
1327
1328 let desc_idx = wrapped_texts
1330 .iter()
1331 .position(|t| t.contains("string appended"))
1332 .expect("Should find 'string appended' line in wrapped output");
1333 assert!(
1334 desc_idx + 1 < wrapped_texts.len(),
1335 "Line should have wrapped, but didn't. Lines: {:?}",
1336 wrapped_texts
1337 );
1338 let continuation = &wrapped_texts[desc_idx + 1];
1339 eprintln!("[TEST] continuation: {:?}", continuation);
1340
1341 let orig_indent = count_leading_spaces(desc_line);
1343 let cont_indent = count_leading_spaces(continuation);
1344 eprintln!(
1345 "[TEST] orig_indent={}, cont_indent={}",
1346 orig_indent, cont_indent
1347 );
1348 assert_eq!(
1349 cont_indent, orig_indent,
1350 "Continuation line should have same indent as original"
1351 );
1352 }
1353}