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