Skip to main content

fresh/view/
markdown.rs

1//! Markdown parsing and rendering for terminal display
2//!
3//! This module provides markdown-to-styled-text conversion for popups,
4//! hover documentation, and other UI elements. It also provides word
5//! wrapping utilities for styled text.
6
7use 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
13/// Word-wrap a single line of text to fit within a given width.
14/// Breaks at word boundaries (spaces) when possible.
15/// Falls back to character-based breaking for words longer than max_width.
16/// Returns a vector of wrapped line segments.
17pub 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    // Split into words while preserving spaces
27    let mut chars = text.chars().peekable();
28    while chars.peek().is_some() {
29        // Collect a "word" (non-space characters) or a space sequence
30        let mut word = String::new();
31        let mut word_width = 0;
32
33        // Collect spaces first
34        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        // Then collect non-space characters
45        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        // Check if word fits on current line
60        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            // Word is too long for a single line, must break mid-word
65            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            // Start a new line with this word
77            result.push(current_line);
78            // Trim leading spaces from the word when starting a new line
79            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
92/// Word-wrap a vector of text lines to fit within a given width.
93pub 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
105/// Word-wrap styled lines to fit within a given width.
106/// Breaks at word boundaries (spaces) when possible, preserving styling.
107pub 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        // Calculate the total width of this line
116        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            // Line fits, no wrapping needed
124            result.push(line.clone());
125        } else {
126            // Flatten spans into styled segments (word + spaces), preserving link URLs
127            let mut segments: Vec<(String, Style, Option<String>)> = Vec::new();
128
129            for span in &line.spans {
130                // Split span text into words and spaces while preserving style and link
131                let mut chars = span.text.chars().peekable();
132                while chars.peek().is_some() {
133                    let mut segment = String::new();
134
135                    // Collect spaces
136                    while let Some(&ch) = chars.peek() {
137                        if ch != ' ' {
138                            break;
139                        }
140                        segment.push(ch);
141                        chars.next();
142                    }
143
144                    // Collect non-spaces (word)
145                    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            // Now wrap using word boundaries
160            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                    // Segment fits
168                    current_line.push_with_link(segment, style, link_url);
169                    current_width += seg_width;
170                } else if current_width == 0 {
171                    // Segment too long for a line, must break mid-word
172                    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                        // Find how many chars fit
183                        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                    // Start new line with this segment
206                    result.push(current_line);
207                    current_line = StyledLine::new();
208                    // For styled content (code, etc.), preserve spacing
209                    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/// A styled span for markdown rendering
224#[derive(Debug, Clone, PartialEq)]
225pub struct StyledSpan {
226    pub text: String,
227    pub style: Style,
228    /// Optional URL if this span is part of a link
229    pub link_url: Option<String>,
230}
231
232/// A line of styled spans for markdown rendering
233#[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    /// Push a span with an optional link URL
252    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    /// Find the link URL at the given column position (0-indexed)
261    /// Returns None if there's no link at that position
262    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                // Found the span at this column
268                return span.link_url.as_deref();
269            }
270            current_col += span_width;
271        }
272        None
273    }
274
275    /// Get the plain text content (without styling)
276    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
287/// Convert highlight spans to styled lines for code blocks
288fn 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        // Add unhighlighted text before this span
302        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        // Add highlighted text
312        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    // Add remaining unhighlighted text
323    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
335/// Helper to add code text to styled lines, handling newlines
336fn 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
349/// Parse markdown text into styled lines for terminal rendering
350///
351/// If `registry` is provided, uses syntect for syntax highlighting in code blocks,
352/// which supports ~150+ languages. If None, falls back to uniform code styling.
353pub 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    // Style stack for nested formatting
365    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    // Track current link URL (if inside a link)
369    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                        // Start new line for code block
394                        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                        // Store the link URL for text spans inside this link
408                        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                        // Start list items on new line
417                        if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
418                            lines.push(StyledLine::new());
419                        }
420                    }
421                    Tag::Paragraph => {
422                        // Start paragraphs on new line if we have any prior content.
423                        // This preserves blank lines from previous paragraph ends.
424                        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                        // Clear link URL when exiting the link
444                        current_link_url = None;
445                    }
446                    TagEnd::CodeBlock => {
447                        in_code_block = false;
448                        code_block_lang.clear();
449                        // End code block with new line
450                        lines.push(StyledLine::new());
451                    }
452                    TagEnd::Paragraph => {
453                        // Add blank line after paragraph
454                        lines.push(StyledLine::new());
455                    }
456                    TagEnd::Item => {
457                        // Items end naturally
458                    }
459                    _ => {}
460                }
461            }
462            Event::Text(text) => {
463                if in_code_block {
464                    // Try syntax highlighting for code blocks using syntect
465                    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                            // Check coverage - if < 20% highlighted, content may not be valid code
469                            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                            // Merge spans into the current line
495                            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                        // Fallback: uniform code style for unknown languages
503                        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                    // Split text by newlines and add to lines
520                    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                                // Include link URL if we're inside a link
527                                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                // Inline code - render with background styling (no backticks needed)
539                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                // Soft break - preserve as newline for better docstring/hover formatting
548                // (Standard markdown renders soft breaks as spaces, but for LSP hover
549                // content which often contains formatted docstrings, newlines are better)
550                lines.push(StyledLine::new());
551            }
552            Event::HardBreak => {
553                // Hard break - new line
554                lines.push(StyledLine::new());
555            }
556            Event::Rule => {
557                // Horizontal rule
558                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    // Remove trailing empty lines
569    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        // Check that "bold" span has BOLD modifier
610        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        // Inline code is rendered without backticks (styling indicates it's code)
669        assert_eq!(get_line_text(&lines[0]), "Use println! to print");
670
671        // Inline code should have background color
672        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        // Code block should have content with background
686        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        // With syntax highlighting, "fn" may be in its own span
690        // Check that at least one span has background color
691        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        // Rust code with keywords and strings that should get different colors
705        let markdown = "```rust\nfn main() {\n    println!(\"Hello\");\n}\n```";
706        let lines = parse_markdown(markdown, &theme, Some(&registry));
707
708        // Should have parsed lines with content
709        assert!(!lines.is_empty(), "Should have parsed lines");
710
711        // Collect all colors used in the code block
712        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        // Should have multiple different colors (syntax highlighting)
722        // Not just a single uniform color
723        assert!(
724            colors_used.len() > 1,
725            "Code block should have multiple colors for syntax highlighting, got: {:?}",
726            colors_used
727        );
728
729        // Verify the code content is preserved
730        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        // Unknown language should fallback to uniform styling
740        let markdown = "```unknownlang\nsome code here\n```";
741        let lines = parse_markdown(markdown, &theme, None);
742
743        // Should have parsed lines
744        assert!(!lines.is_empty(), "Should have parsed lines");
745
746        // Content should be preserved
747        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        // All spans should have the fallback code style (uniform color)
754        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        // Heading should be bold
770        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        // Link text should be underlined and cyan
787        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        // The "here" span should have the link URL stored
805        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        // Non-link spans should not have a URL
814        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        // "Click " is 6 chars (0-5), "here" is 4 chars (6-9), " for more" is after
832        // Column 0-5: "Click " - no link
833        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        // Column 6-9: "here" - link
845        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        // Column 10+: " for more" - no link
857        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        // Each item should be on its own line
870        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        // Should have 3 lines: first para, blank line, second para
884        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        // Single newline in markdown is a soft break
903        let lines = parse_markdown("Line one\nLine two", &theme, None);
904
905        // Soft break should become a newline for better docstring/hover formatting
906        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        // Two spaces before newline creates a hard break
922        let lines = parse_markdown("Line one  \nLine two", &theme, None);
923
924        // Hard break creates a new line within the same paragraph
925        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        // Should have a line with horizontal rule characters
934        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        // Find the nested formatted span
946        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        // Real-world example from Python LSP hover
963        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        // Should have code block, blank line, first paragraph, blank line, second paragraph
969        assert!(lines.len() >= 3, "Should have multiple sections");
970
971        // Code block should have background
972        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        // Documentation text should be present
976        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        // Test Python-style docstring with keyword arguments list
986        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        // Should preserve line breaks for proper list formatting
991        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        // Empty input should produce empty or minimal output
1005        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        // Whitespace-only should produce empty or minimal output
1017        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    // ==================== Word Wrapping Tests ====================
1027
1028    #[test]
1029    fn test_wrap_text_line_at_word_boundaries() {
1030        // Test that wrapping happens at word boundaries, not mid-word
1031        let text = "Path represents a filesystem path but unlike PurePath also offers methods";
1032        let wrapped = wrap_text_line(text, 30);
1033
1034        // Should wrap at word boundaries
1035        for (i, line) in wrapped.iter().enumerate() {
1036            // Lines should not start with a space (spaces are trimmed when wrapping)
1037            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            // Each line should fit within max_width
1047            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        // Check that we didn't break any words mid-character
1058        // All words in wrapped output should be complete words from original
1059        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        // Verify specific expected wrapping (28 chars fits: "Path represents a filesystem")
1070        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        // Test that words longer than max_width are broken mid-word
1087        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        // Each line should be at most max_width
1096        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        // Content should be preserved
1102        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        // Test that long hover lines get wrapped correctly
1124        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1125
1126        // Simulate a long LSP hover response (e.g., a function signature that's too long)
1127        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        // The code block should produce styled lines
1133        assert!(!lines.is_empty(), "Should have parsed lines");
1134
1135        // Now wrap to a narrow width (40 chars)
1136        let wrapped = wrap_styled_lines(&lines, 40);
1137
1138        // The long line should be wrapped into multiple lines
1139        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        // Each wrapped line should not exceed max width
1147        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        // Verify the content is preserved (concatenate all wrapped text)
1166        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        // All wrapped segments should preserve the bold style
1188        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        // Should preserve empty lines
1213        assert!(
1214            wrapped.iter().any(|l| l.is_empty()),
1215            "Should preserve empty lines"
1216        );
1217
1218        // All lines should fit within max_width
1219        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}