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