use crate::primitives::grammar::GrammarRegistry;
use crate::primitives::highlight_engine::highlight_string;
use crate::primitives::highlighter::HighlightSpan;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};
pub fn wrap_text_line(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![text.to_string()];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut chars = text.chars().peekable();
while chars.peek().is_some() {
let mut word = String::new();
let mut word_width = 0;
while let Some(&ch) = chars.peek() {
if ch != ' ' {
break;
}
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
word.push(ch);
word_width += ch_width;
chars.next();
}
while let Some(&ch) = chars.peek() {
if ch == ' ' {
break;
}
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
word.push(ch);
word_width += ch_width;
chars.next();
}
if word.is_empty() {
continue;
}
if current_width + word_width <= max_width {
current_line.push_str(&word);
current_width += word_width;
} else if current_line.is_empty() {
for ch in word.chars() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if current_width + ch_width > max_width && !current_line.is_empty() {
result.push(current_line);
current_line = String::new();
current_width = 0;
}
current_line.push(ch);
current_width += ch_width;
}
} else {
result.push(current_line);
let trimmed = word.trim_start();
current_line = trimmed.to_string();
current_width = unicode_width::UnicodeWidthStr::width(trimmed);
}
}
if !current_line.is_empty() || result.is_empty() {
result.push(current_line);
}
result
}
pub fn wrap_text_lines(lines: &[String], max_width: usize) -> Vec<String> {
let mut result = Vec::new();
for line in lines {
if line.is_empty() {
result.push(String::new());
} else {
result.extend(wrap_text_line(line, max_width));
}
}
result
}
pub fn wrap_styled_lines(lines: &[StyledLine], max_width: usize) -> Vec<StyledLine> {
if max_width == 0 {
return lines.to_vec();
}
let mut result = Vec::new();
for line in lines {
let total_width: usize = line
.spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
.sum();
if total_width <= max_width {
result.push(line.clone());
} else {
let mut segments: Vec<(String, Style, Option<String>)> = Vec::new();
for span in &line.spans {
let mut chars = span.text.chars().peekable();
while chars.peek().is_some() {
let mut segment = String::new();
while let Some(&ch) = chars.peek() {
if ch != ' ' {
break;
}
segment.push(ch);
chars.next();
}
while let Some(&ch) = chars.peek() {
if ch == ' ' {
break;
}
segment.push(ch);
chars.next();
}
if !segment.is_empty() {
segments.push((segment, span.style, span.link_url.clone()));
}
}
}
let mut current_line = StyledLine::new();
let mut current_width = 0;
for (segment, style, link_url) in segments {
let seg_width = unicode_width::UnicodeWidthStr::width(segment.as_str());
if current_width + seg_width <= max_width {
current_line.push_with_link(segment, style, link_url);
current_width += seg_width;
} else if current_width == 0 {
let mut remaining = segment.as_str();
while !remaining.is_empty() {
let available = max_width.saturating_sub(current_width);
if available == 0 {
result.push(current_line);
current_line = StyledLine::new();
current_width = 0;
continue;
}
let mut take_chars = 0;
let mut take_width = 0;
for ch in remaining.chars() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if take_width + w > available && take_chars > 0 {
break;
}
take_width += w;
take_chars += 1;
}
let byte_idx = remaining
.char_indices()
.nth(take_chars)
.map(|(i, _)| i)
.unwrap_or(remaining.len());
let (take, rest) = remaining.split_at(byte_idx);
current_line.push_with_link(take.to_string(), style, link_url.clone());
current_width += take_width;
remaining = rest;
}
} else {
result.push(current_line);
current_line = StyledLine::new();
current_line.push_with_link(segment, style, link_url);
current_width = seg_width;
}
}
if !current_line.spans.is_empty() {
result.push(current_line);
}
}
}
result
}
#[derive(Debug, Clone, PartialEq)]
pub struct StyledSpan {
pub text: String,
pub style: Style,
pub link_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StyledLine {
pub spans: Vec<StyledSpan>,
}
impl StyledLine {
pub fn new() -> Self {
Self { spans: Vec::new() }
}
pub fn push(&mut self, text: String, style: Style) {
self.spans.push(StyledSpan {
text,
style,
link_url: None,
});
}
pub fn push_with_link(&mut self, text: String, style: Style, link_url: Option<String>) {
self.spans.push(StyledSpan {
text,
style,
link_url,
});
}
pub fn link_at_column(&self, column: usize) -> Option<&str> {
let mut current_col = 0;
for span in &self.spans {
let span_width = unicode_width::UnicodeWidthStr::width(span.text.as_str());
if column >= current_col && column < current_col + span_width {
return span.link_url.as_deref();
}
current_col += span_width;
}
None
}
pub fn plain_text(&self) -> String {
self.spans.iter().map(|s| s.text.as_str()).collect()
}
}
impl Default for StyledLine {
fn default() -> Self {
Self::new()
}
}
fn highlight_code_to_styled_lines(
code: &str,
spans: &[HighlightSpan],
theme: &crate::view::theme::Theme,
) -> Vec<StyledLine> {
let mut result = vec![StyledLine::new()];
let code_bg = theme.inline_code_bg;
let default_fg = theme.help_key_fg;
let bytes = code.as_bytes();
let mut pos = 0;
for span in spans {
if span.range.start > pos {
let text = String::from_utf8_lossy(&bytes[pos..span.range.start]);
add_code_text_to_lines(
&mut result,
&text,
Style::default().fg(default_fg).bg(code_bg),
);
}
let text = String::from_utf8_lossy(&bytes[span.range.start..span.range.end]);
add_code_text_to_lines(
&mut result,
&text,
Style::default().fg(span.color).bg(code_bg),
);
pos = span.range.end;
}
if pos < bytes.len() {
let text = String::from_utf8_lossy(&bytes[pos..]);
add_code_text_to_lines(
&mut result,
&text,
Style::default().fg(default_fg).bg(code_bg),
);
}
result
}
fn add_code_text_to_lines(lines: &mut Vec<StyledLine>, text: &str, style: Style) {
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
lines.push(StyledLine::new());
}
if !part.is_empty() {
if let Some(line) = lines.last_mut() {
line.push(part.to_string(), style);
}
}
}
}
pub fn parse_markdown(
text: &str,
theme: &crate::view::theme::Theme,
registry: Option<&GrammarRegistry>,
) -> Vec<StyledLine> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(text, options);
let mut lines: Vec<StyledLine> = vec![StyledLine::new()];
let mut style_stack: Vec<Style> = vec![Style::default()];
let mut in_code_block = false;
let mut code_block_lang = String::new();
let mut current_link_url: Option<String> = None;
for event in parser {
match event {
Event::Start(tag) => {
match tag {
Tag::Strong => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack.push(current.add_modifier(Modifier::BOLD));
}
Tag::Emphasis => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack.push(current.add_modifier(Modifier::ITALIC));
}
Tag::Strikethrough => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
}
Tag::CodeBlock(kind) => {
in_code_block = true;
code_block_lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
pulldown_cmark::CodeBlockKind::Indented => String::new(),
};
if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
lines.push(StyledLine::new());
}
}
Tag::Heading { .. } => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack
.push(current.add_modifier(Modifier::BOLD).fg(theme.help_key_fg));
}
Tag::Link { dest_url, .. } => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack
.push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
current_link_url = Some(dest_url.to_string());
}
Tag::Image { .. } => {
let current = *style_stack.last().unwrap_or(&Style::default());
style_stack
.push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
}
Tag::List(_) | Tag::Item => {
if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
lines.push(StyledLine::new());
}
}
Tag::Paragraph => {
let has_prior_content = lines.iter().any(|l| !l.spans.is_empty());
if has_prior_content {
lines.push(StyledLine::new());
}
}
_ => {}
}
}
Event::End(tag_end) => {
match tag_end {
TagEnd::Strong
| TagEnd::Emphasis
| TagEnd::Strikethrough
| TagEnd::Heading(_)
| TagEnd::Image => {
style_stack.pop();
}
TagEnd::Link => {
style_stack.pop();
current_link_url = None;
}
TagEnd::CodeBlock => {
in_code_block = false;
code_block_lang.clear();
lines.push(StyledLine::new());
}
TagEnd::Paragraph => {
lines.push(StyledLine::new());
}
TagEnd::Item => {
}
_ => {}
}
}
Event::Text(text) => {
if in_code_block {
let spans = if let Some(reg) = registry {
if !code_block_lang.is_empty() {
let s = highlight_string(&text, &code_block_lang, reg, theme);
let highlighted_bytes: usize =
s.iter().map(|span| span.range.end - span.range.start).sum();
let non_ws_bytes =
text.bytes().filter(|b| !b.is_ascii_whitespace()).count();
let good_coverage =
non_ws_bytes == 0 || highlighted_bytes * 5 >= non_ws_bytes;
if good_coverage {
s
} else {
Vec::new()
}
} else {
Vec::new()
}
} else {
Vec::new()
};
if !spans.is_empty() {
let highlighted_lines =
highlight_code_to_styled_lines(&text, &spans, theme);
for (i, styled_line) in highlighted_lines.into_iter().enumerate() {
if i > 0 {
lines.push(StyledLine::new());
}
if let Some(current_line) = lines.last_mut() {
for span in styled_line.spans {
current_line.push(span.text, span.style);
}
}
}
} else {
let code_style = Style::default()
.fg(theme.help_key_fg)
.bg(theme.inline_code_bg);
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
lines.push(StyledLine::new());
}
if !part.is_empty() {
if let Some(line) = lines.last_mut() {
line.push(part.to_string(), code_style);
}
}
}
}
} else {
let current_style = *style_stack.last().unwrap_or(&Style::default());
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
lines.push(StyledLine::new());
}
if !part.is_empty() {
if let Some(line) = lines.last_mut() {
line.push_with_link(
part.to_string(),
current_style,
current_link_url.clone(),
);
}
}
}
}
}
Event::Code(code) => {
let style = Style::default()
.fg(theme.help_key_fg)
.bg(theme.inline_code_bg);
if let Some(line) = lines.last_mut() {
line.push(code.to_string(), style);
}
}
Event::SoftBreak => {
lines.push(StyledLine::new());
}
Event::HardBreak => {
lines.push(StyledLine::new());
}
Event::Rule => {
lines.push(StyledLine::new());
if let Some(line) = lines.last_mut() {
line.push("─".repeat(40), Style::default().fg(Color::DarkGray));
}
lines.push(StyledLine::new());
}
_ => {}
}
}
while lines.last().map(|l| l.spans.is_empty()).unwrap_or(false) {
lines.pop();
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::theme;
use crate::view::theme::Theme;
fn get_line_text(line: &StyledLine) -> String {
line.spans.iter().map(|s| s.text.as_str()).collect()
}
fn has_modifier(line: &StyledLine, modifier: Modifier) -> bool {
line.spans
.iter()
.any(|s| s.style.add_modifier.contains(modifier))
}
#[test]
fn test_plain_text() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("Hello world", &theme, None);
assert_eq!(lines.len(), 1);
assert_eq!(get_line_text(&lines[0]), "Hello world");
}
#[test]
fn test_bold_text() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("This is **bold** text", &theme, None);
assert_eq!(lines.len(), 1);
assert_eq!(get_line_text(&lines[0]), "This is bold text");
let bold_span = lines[0].spans.iter().find(|s| s.text == "bold");
assert!(bold_span.is_some(), "Should have a 'bold' span");
assert!(
bold_span
.unwrap()
.style
.add_modifier
.contains(Modifier::BOLD),
"Bold span should have BOLD modifier"
);
}
#[test]
fn test_italic_text() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("This is *italic* text", &theme, None);
assert_eq!(lines.len(), 1);
assert_eq!(get_line_text(&lines[0]), "This is italic text");
let italic_span = lines[0].spans.iter().find(|s| s.text == "italic");
assert!(italic_span.is_some(), "Should have an 'italic' span");
assert!(
italic_span
.unwrap()
.style
.add_modifier
.contains(Modifier::ITALIC),
"Italic span should have ITALIC modifier"
);
}
#[test]
fn test_strikethrough_text() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("This is ~~deleted~~ text", &theme, None);
assert_eq!(lines.len(), 1);
assert_eq!(get_line_text(&lines[0]), "This is deleted text");
let strike_span = lines[0].spans.iter().find(|s| s.text == "deleted");
assert!(strike_span.is_some(), "Should have a 'deleted' span");
assert!(
strike_span
.unwrap()
.style
.add_modifier
.contains(Modifier::CROSSED_OUT),
"Strikethrough span should have CROSSED_OUT modifier"
);
}
#[test]
fn test_inline_code() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("Use `println!` to print", &theme, None);
assert_eq!(lines.len(), 1);
assert_eq!(get_line_text(&lines[0]), "Use println! to print");
let code_span = lines[0].spans.iter().find(|s| s.text.contains("println"));
assert!(code_span.is_some(), "Should have a code span");
assert!(
code_span.unwrap().style.bg.is_some(),
"Inline code should have background color"
);
}
#[test]
fn test_code_block() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("```rust\nfn main() {}\n```", &theme, None);
let code_line = lines.iter().find(|l| get_line_text(l).contains("fn"));
assert!(code_line.is_some(), "Should have code block content");
let has_bg = code_line
.unwrap()
.spans
.iter()
.any(|s| s.style.bg.is_some());
assert!(has_bg, "Code block should have background color");
}
#[test]
fn test_code_block_syntax_highlighting() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::new());
let markdown = "```rust\nfn main() {\n println!(\"Hello\");\n}\n```";
let lines = parse_markdown(markdown, &theme, Some(®istry));
assert!(!lines.is_empty(), "Should have parsed lines");
let mut colors_used = std::collections::HashSet::new();
for line in &lines {
for span in &line.spans {
if let Some(fg) = span.style.fg {
colors_used.insert(format!("{:?}", fg));
}
}
}
assert!(
colors_used.len() > 1,
"Code block should have multiple colors for syntax highlighting, got: {:?}",
colors_used
);
let all_text: String = lines.iter().map(get_line_text).collect::<Vec<_>>().join("");
assert!(all_text.contains("fn"), "Should contain 'fn' keyword");
assert!(all_text.contains("main"), "Should contain 'main'");
assert!(all_text.contains("println"), "Should contain 'println'");
}
#[test]
fn test_code_block_unknown_language_fallback() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let markdown = "```unknownlang\nsome code here\n```";
let lines = parse_markdown(markdown, &theme, None);
assert!(!lines.is_empty(), "Should have parsed lines");
let all_text: String = lines.iter().map(get_line_text).collect::<Vec<_>>().join("");
assert!(
all_text.contains("some code here"),
"Should contain the code"
);
let code_line = lines
.iter()
.find(|l| get_line_text(l).contains("some code"));
if let Some(line) = code_line {
for span in &line.spans {
assert!(span.style.bg.is_some(), "Code should have background color");
}
}
}
#[test]
fn test_heading() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("# Heading\n\nContent", &theme, None);
let heading_line = &lines[0];
assert!(
has_modifier(heading_line, Modifier::BOLD),
"Heading should be bold"
);
assert_eq!(get_line_text(heading_line), "Heading");
}
#[test]
fn test_link() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
assert_eq!(lines.len(), 1);
assert_eq!(get_line_text(&lines[0]), "Click here for more");
let link_span = lines[0].spans.iter().find(|s| s.text == "here");
assert!(link_span.is_some(), "Should have 'here' span");
let style = link_span.unwrap().style;
assert!(
style.add_modifier.contains(Modifier::UNDERLINED),
"Link should be underlined"
);
assert_eq!(style.fg, Some(Color::Cyan), "Link should be cyan");
}
#[test]
fn test_link_url_stored() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
assert_eq!(lines.len(), 1);
let link_span = lines[0].spans.iter().find(|s| s.text == "here");
assert!(link_span.is_some(), "Should have 'here' span");
assert_eq!(
link_span.unwrap().link_url,
Some("https://example.com".to_string()),
"Link span should store the URL"
);
let click_span = lines[0].spans.iter().find(|s| s.text == "Click ");
assert!(click_span.is_some(), "Should have 'Click ' span");
assert_eq!(
click_span.unwrap().link_url,
None,
"Non-link span should not have URL"
);
}
#[test]
fn test_link_at_column() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
assert_eq!(lines.len(), 1);
let line = &lines[0];
assert_eq!(
line.link_at_column(0),
None,
"Column 0 should not be a link"
);
assert_eq!(
line.link_at_column(5),
None,
"Column 5 should not be a link"
);
assert_eq!(
line.link_at_column(6),
Some("https://example.com"),
"Column 6 should be the link"
);
assert_eq!(
line.link_at_column(9),
Some("https://example.com"),
"Column 9 should be the link"
);
assert_eq!(
line.link_at_column(10),
None,
"Column 10 should not be a link"
);
}
#[test]
fn test_unordered_list() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("- Item 1\n- Item 2\n- Item 3", &theme, None);
assert!(lines.len() >= 3, "Should have at least 3 lines for 3 items");
let all_text: String = lines.iter().map(get_line_text).collect();
assert!(all_text.contains("Item 1"), "Should contain Item 1");
assert!(all_text.contains("Item 2"), "Should contain Item 2");
assert!(all_text.contains("Item 3"), "Should contain Item 3");
}
#[test]
fn test_paragraph_separation() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("First paragraph.\n\nSecond paragraph.", &theme, None);
assert_eq!(
lines.len(),
3,
"Should have 3 lines (para, blank, para), got: {:?}",
lines.iter().map(get_line_text).collect::<Vec<_>>()
);
assert_eq!(get_line_text(&lines[0]), "First paragraph.");
assert!(
lines[1].spans.is_empty(),
"Second line should be empty (paragraph break)"
);
assert_eq!(get_line_text(&lines[2]), "Second paragraph.");
}
#[test]
fn test_soft_break_becomes_newline() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("Line one\nLine two", &theme, None);
assert!(
lines.len() >= 2,
"Soft break should create separate lines, got {} lines",
lines.len()
);
let all_text: String = lines.iter().map(get_line_text).collect();
assert!(
all_text.contains("one") && all_text.contains("two"),
"Should contain both lines"
);
}
#[test]
fn test_hard_break() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("Line one \nLine two", &theme, None);
assert!(lines.len() >= 2, "Hard break should create multiple lines");
}
#[test]
fn test_horizontal_rule() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("Above\n\n---\n\nBelow", &theme, None);
let has_rule = lines.iter().any(|l| get_line_text(l).contains("─"));
assert!(has_rule, "Should contain horizontal rule character");
}
#[test]
fn test_nested_formatting() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("This is ***bold and italic*** text", &theme, None);
assert_eq!(lines.len(), 1);
let nested_span = lines[0].spans.iter().find(|s| s.text == "bold and italic");
assert!(nested_span.is_some(), "Should have nested formatted span");
let style = nested_span.unwrap().style;
assert!(
style.add_modifier.contains(Modifier::BOLD),
"Should be bold"
);
assert!(
style.add_modifier.contains(Modifier::ITALIC),
"Should be italic"
);
}
#[test]
fn test_lsp_hover_docstring() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let markdown = "```python\n(class) Path\n```\n\nPurePath subclass that can make system calls.\n\nPath represents a filesystem path.";
let lines = parse_markdown(markdown, &theme, None);
assert!(lines.len() >= 3, "Should have multiple sections");
let code_line = lines.iter().find(|l| get_line_text(l).contains("Path"));
assert!(code_line.is_some(), "Should have code block with Path");
let all_text: String = lines.iter().map(get_line_text).collect();
assert!(
all_text.contains("PurePath subclass"),
"Should contain docstring"
);
}
#[test]
fn test_python_docstring_formatting() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let markdown = "Keyword Arguments:\n - prog -- The name\n - usage -- A usage message";
let lines = parse_markdown(markdown, &theme, None);
assert!(
lines.len() >= 3,
"Should have multiple lines for keyword args list, got {} lines: {:?}",
lines.len(),
lines.iter().map(get_line_text).collect::<Vec<_>>()
);
}
#[test]
fn test_empty_input() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("", &theme, None);
assert!(
lines.is_empty() || (lines.len() == 1 && lines[0].spans.is_empty()),
"Empty input should produce empty output"
);
}
#[test]
fn test_only_whitespace() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown(" \n\n ", &theme, None);
for line in &lines {
let text = get_line_text(line);
assert!(
text.trim().is_empty(),
"Whitespace-only input should not produce content"
);
}
}
#[test]
fn test_wrap_text_line_at_word_boundaries() {
let text = "Path represents a filesystem path but unlike PurePath also offers methods";
let wrapped = wrap_text_line(text, 30);
for (i, line) in wrapped.iter().enumerate() {
if !line.is_empty() {
assert!(
!line.starts_with(' '),
"Line {} should not start with space: {:?}",
i,
line
);
}
let line_width = unicode_width::UnicodeWidthStr::width(line.as_str());
assert!(
line_width <= 30,
"Line {} exceeds max width: {} > 30, content: {:?}",
i,
line_width,
line
);
}
let original_words: Vec<&str> = text.split_whitespace().collect();
let wrapped_words: Vec<&str> = wrapped
.iter()
.flat_map(|line| line.split_whitespace())
.collect();
assert_eq!(
original_words, wrapped_words,
"Words should be preserved without breaking mid-word"
);
assert_eq!(
wrapped[0], "Path represents a filesystem",
"First line should break at word boundary"
);
assert_eq!(
wrapped[1], "path but unlike PurePath also",
"Second line should contain next words (30 chars fits)"
);
assert_eq!(
wrapped[2], "offers methods",
"Third line should contain remaining words"
);
}
#[test]
fn test_wrap_text_line_long_word() {
let text = "supercalifragilisticexpialidocious";
let wrapped = wrap_text_line(text, 10);
assert!(
wrapped.len() > 1,
"Long word should be split into multiple lines"
);
for line in &wrapped {
let width = unicode_width::UnicodeWidthStr::width(line.as_str());
assert!(width <= 10, "Line should not exceed max width: {}", line);
}
let rejoined: String = wrapped.join("");
assert_eq!(rejoined, text, "Content should be preserved");
}
#[test]
fn test_wrap_text_line_empty() {
let wrapped = wrap_text_line("", 30);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0], "");
}
#[test]
fn test_wrap_text_line_fits() {
let text = "Short text";
let wrapped = wrap_text_line(text, 30);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0], text);
}
#[test]
fn test_wrap_styled_lines_long_hover_content() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let long_text = "def very_long_function_name(param1: str, param2: int, param3: float, param4: list, param5: dict) -> tuple[str, int, float]";
let markdown = format!("```python\n{}\n```", long_text);
let lines = parse_markdown(&markdown, &theme, None);
assert!(!lines.is_empty(), "Should have parsed lines");
let wrapped = wrap_styled_lines(&lines, 40);
assert!(
wrapped.len() > lines.len(),
"Long line should wrap into multiple lines. Original: {}, Wrapped: {}",
lines.len(),
wrapped.len()
);
for (i, line) in wrapped.iter().enumerate() {
let line_width: usize = line
.spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
.sum();
assert!(
line_width <= 40,
"Wrapped line {} exceeds max width: {} > 40, content: {:?}",
i,
line_width,
line.spans
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
);
}
let original_text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
.collect();
let wrapped_text: String = wrapped
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
.collect();
assert_eq!(
original_text, wrapped_text,
"Content should be preserved after wrapping"
);
}
#[test]
fn test_wrap_styled_lines_preserves_style() {
let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
let lines = parse_markdown("**bold text that is quite long**", &theme, None);
let wrapped = wrap_styled_lines(&lines, 15);
for line in &wrapped {
for span in &line.spans {
if !span.text.trim().is_empty() {
assert!(
span.style.add_modifier.contains(Modifier::BOLD),
"Style should be preserved after wrapping: {:?}",
span.text
);
}
}
}
}
#[test]
fn test_wrap_text_lines_multiple() {
let lines = vec![
"Short".to_string(),
"This is a longer line that needs wrapping".to_string(),
"".to_string(),
"Another line".to_string(),
];
let wrapped = wrap_text_lines(&lines, 20);
assert!(
wrapped.iter().any(|l| l.is_empty()),
"Should preserve empty lines"
);
for line in &wrapped {
let width = unicode_width::UnicodeWidthStr::width(line.as_str());
assert!(width <= 20, "Line exceeds max width: {}", line);
}
}
}