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/// Whether a character is a space-like separator (regular space or NBSP).
14fn is_space(ch: char) -> bool {
15    ch == ' ' || ch == '\u{00A0}'
16}
17
18/// Calculate hanging indent width from leading spaces, clamped so that
19/// at least 10 characters of content remain.
20fn 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
28/// Count the number of leading space-like characters (space or NBSP) in a string.
29fn count_leading_spaces(text: &str) -> usize {
30    text.chars().take_while(|&ch| is_space(ch)).count()
31}
32
33/// Word-wrap a single line of text to fit within a given width.
34/// Breaks at word boundaries (spaces) when possible.
35/// Falls back to character-based breaking for words longer than max_width.
36/// Continuation lines are indented to match the original line's leading whitespace.
37pub 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        // Word fits on current line
51        if current_width + word_width <= max_width {
52            current_line.push_str(&word);
53            current_width += word_width;
54            continue;
55        }
56
57        // First word on line but too long — break mid-word
58        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        // Start a new line with hanging indent
73        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
86/// Word-wrap a vector of text lines to fit within a given width.
87pub 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
99/// Word-wrap styled lines to fit within a given width.
100/// Breaks at word boundaries (spaces) when possible, preserving styling.
101/// Continuation lines are indented to match the original line's leading whitespace.
102pub 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        // Calculate leading indent across spans (space or NBSP)
122        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        // Flatten spans into (text, style, link_url) segments split at word boundaries
138        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            // Segment fits on current line
147            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            // First segment on line but too long — break mid-word
154            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            // Start new continuation line with hanging indent
174            result.push(current_line);
175            current_line = new_continuation_line(indent_width);
176            // Trim leading space/NBSP — either replaced by hanging indent or
177            // just a word separator that shouldn't start a new line.
178            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
192/// Create a new `StyledLine` pre-filled with hanging indent spaces.
193fn 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
201/// Split `text` so the first part fits within `available` display columns.
202/// Returns (taken, remaining).
203fn 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
222/// Flatten styled spans into word-boundary segments, preserving style and link info.
223fn 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
233/// Iterator that splits text into word segments (spaces + non-spaces),
234/// yielding `(segment_text, display_width)` pairs.
235struct 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        // Collect leading spaces (regular or NBSP)
257        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        // Collect non-space characters
268        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/// A styled span for markdown rendering
287#[derive(Debug, Clone, PartialEq)]
288pub struct StyledSpan {
289    pub text: String,
290    pub style: Style,
291    /// Optional URL if this span is part of a link
292    pub link_url: Option<String>,
293}
294
295/// A line of styled spans for markdown rendering
296#[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    /// Push a span with an optional link URL
315    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    /// Find the link URL at the given column position (0-indexed)
324    /// Returns None if there's no link at that position
325    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                // Found the span at this column
331                return span.link_url.as_deref();
332            }
333            current_col += span_width;
334        }
335        None
336    }
337
338    /// Get the plain text content (without styling)
339    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
350/// Convert highlight spans to styled lines for code blocks
351fn 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        // Add unhighlighted text before this span
365        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        // Add highlighted text
376        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    // Add remaining unhighlighted text
388    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
401/// Add text to styled lines, splitting on newlines.
402/// Each `\n` starts a new `StyledLine`. Non-empty segments are pushed with
403/// the given style and optional link URL.
404fn 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
422/// Preserve leading whitespace in text by replacing leading regular spaces
423/// with non-breaking spaces. Markdown parsers strip leading spaces from
424/// paragraphs, but LSP documentation (e.g. Python docstrings) uses indentation
425/// for structure. Non-breaking spaces survive markdown parsing.
426fn 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
440/// Parse markdown text into styled lines for terminal rendering
441///
442/// If `registry` is provided, uses syntect for syntax highlighting in code blocks,
443/// which supports ~150+ languages. If None, falls back to uniform code styling.
444pub fn parse_markdown(
445    text: &str,
446    theme: &crate::view::theme::Theme,
447    registry: Option<&GrammarRegistry>,
448) -> Vec<StyledLine> {
449    // Preserve leading whitespace (as NBSP) before markdown parsing,
450    // since pulldown_cmark strips leading spaces from paragraph text.
451    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    // Style stack for nested formatting
460    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    // Track current link URL (if inside a link)
464    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                        // Start new line for code block
489                        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                        // Store the link URL for text spans inside this link
503                        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                        // Start list items on new line
512                        if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
513                            lines.push(StyledLine::new());
514                        }
515                    }
516                    Tag::Paragraph => {
517                        // Start paragraphs on new line if we have any prior content.
518                        // This preserves blank lines from previous paragraph ends.
519                        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                        // Clear link URL when exiting the link
539                        current_link_url = None;
540                    }
541                    TagEnd::CodeBlock => {
542                        in_code_block = false;
543                        code_block_lang.clear();
544                        // End code block with new line
545                        lines.push(StyledLine::new());
546                    }
547                    TagEnd::Paragraph => {
548                        // Add blank line after paragraph
549                        lines.push(StyledLine::new());
550                    }
551                    TagEnd::Item => {
552                        // Items end naturally
553                    }
554                    _ => {}
555                }
556            }
557            Event::Text(text) => {
558                if in_code_block {
559                    // Try syntax highlighting for code blocks using syntect
560                    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                            // Check coverage - if < 20% highlighted, content may not be valid code
564                            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                            // Merge spans into the current line
590                            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                        // Fallback: uniform code style for unknown languages
598                        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                // Inline code - render with background styling (no backticks needed)
610                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                // Soft break - preserve as newline for better docstring/hover formatting
619                // (Standard markdown renders soft breaks as spaces, but for LSP hover
620                // content which often contains formatted docstrings, newlines are better)
621                lines.push(StyledLine::new());
622            }
623            Event::HardBreak => {
624                // Hard break - new line
625                lines.push(StyledLine::new());
626            }
627            Event::Rule => {
628                // Horizontal rule
629                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    // Remove trailing empty lines
640    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        // Check that "bold" span has BOLD modifier
677        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        // Inline code is rendered without backticks (styling indicates it's code)
736        assert_eq!(lines[0].plain_text(), "Use println! to print");
737
738        // Inline code should have background color
739        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        // Code block should have content with background
753        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        // With syntax highlighting, "fn" may be in its own span
757        // Check that at least one span has background color
758        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        // Rust code with keywords and strings that should get different colors
772        let markdown = "```rust\nfn main() {\n    println!(\"Hello\");\n}\n```";
773        let lines = parse_markdown(markdown, &theme, Some(&registry));
774
775        // Should have parsed lines with content
776        assert!(!lines.is_empty(), "Should have parsed lines");
777
778        // Collect all colors used in the code block
779        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        // Should have multiple different colors (syntax highlighting)
789        // Not just a single uniform color
790        assert!(
791            colors_used.len() > 1,
792            "Code block should have multiple colors for syntax highlighting, got: {:?}",
793            colors_used
794        );
795
796        // Verify the code content is preserved
797        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        // Unknown language should fallback to uniform styling
811        let markdown = "```unknownlang\nsome code here\n```";
812        let lines = parse_markdown(markdown, &theme, None);
813
814        // Should have parsed lines
815        assert!(!lines.is_empty(), "Should have parsed lines");
816
817        // Content should be preserved
818        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        // All spans should have the fallback code style (uniform color)
829        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        // Heading should be bold
843        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        // Link text should be underlined and cyan
860        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        // The "here" span should have the link URL stored
878        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        // Non-link spans should not have a URL
887        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        // "Click " is 6 chars (0-5), "here" is 4 chars (6-9), " for more" is after
905        // Column 0-5: "Click " - no link
906        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        // Column 6-9: "here" - link
918        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        // Column 10+: " for more" - no link
930        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        // Each item should be on its own line
943        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        // Should have 3 lines: first para, blank line, second para
957        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        // Single newline in markdown is a soft break
976        let lines = parse_markdown("Line one\nLine two", &theme, None);
977
978        // Soft break should become a newline for better docstring/hover formatting
979        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        // Two spaces before newline creates a hard break
995        let lines = parse_markdown("Line one  \nLine two", &theme, None);
996
997        // Hard break creates a new line within the same paragraph
998        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        // Should have a line with horizontal rule characters
1007        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        // Find the nested formatted span
1019        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        // Real-world example from Python LSP hover
1036        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        // Should have code block, blank line, first paragraph, blank line, second paragraph
1042        assert!(lines.len() >= 3, "Should have multiple sections");
1043
1044        // Code block should have background
1045        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        // Documentation text should be present
1049        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        // Test Python-style docstring with keyword arguments list
1059        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        // Should preserve line breaks for proper list formatting
1064        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        // Empty input should produce empty or minimal output
1078        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        // Whitespace-only should produce empty or minimal output
1090        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    // ==================== Word Wrapping Tests ====================
1100
1101    #[test]
1102    fn test_wrap_text_line_at_word_boundaries() {
1103        // Test that wrapping happens at word boundaries, not mid-word
1104        let text = "Path represents a filesystem path but unlike PurePath also offers methods";
1105        let wrapped = wrap_text_line(text, 30);
1106
1107        // Should wrap at word boundaries
1108        for (i, line) in wrapped.iter().enumerate() {
1109            // Lines should not start with a space (spaces are trimmed when wrapping)
1110            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            // Each line should fit within max_width
1120            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        // Check that we didn't break any words mid-character
1131        // All words in wrapped output should be complete words from original
1132        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        // Verify specific expected wrapping (28 chars fits: "Path represents a filesystem")
1143        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        // Test that words longer than max_width are broken mid-word
1160        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        // Each line should be at most max_width
1169        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        // Content should be preserved
1175        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        // Test that long hover lines get wrapped correctly
1197        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1198
1199        // Simulate a long LSP hover response (e.g., a function signature that's too long)
1200        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        // The code block should produce styled lines
1206        assert!(!lines.is_empty(), "Should have parsed lines");
1207
1208        // Now wrap to a narrow width (40 chars)
1209        let wrapped = wrap_styled_lines(&lines, 40);
1210
1211        // The long line should be wrapped into multiple lines
1212        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        // Each wrapped line should not exceed max width
1220        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        // Verify no content is lost (spaces at wrap points are trimmed, which is expected)
1239        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        // All wrapped segments should preserve the bold style
1262        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        // Should preserve empty lines
1287        assert!(
1288            wrapped.iter().any(|l| l.is_empty()),
1289            "Should preserve empty lines"
1290        );
1291
1292        // All lines should fit within max_width
1293        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        // Simulate the markdown content produced by signature help for print()
1302        // The doc text from pyright uses blank lines between param name and description
1303        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        // Find the line with "string appended" - it should have leading spaces
1314        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        // Now test wrapping at width 40 (narrow popup to force wrapping)
1321        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        // Find continuation of "string appended" line
1329        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        // Continuation should have indent (spaces matching the NBSP indent)
1342        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}