1use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
6use ratatui::{
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9};
10
11use super::table::{is_table_line, is_table_separator, render_table};
12use super::themes::Theme;
13
14const ASSISTANT_PREFIX: &str = "\u{25C6} "; const CONTINUATION: &str = " ";
17
18pub fn parse_to_spans(text: &str, theme: &Theme) -> Vec<Span<'static>> {
20 let mut options = Options::empty();
21 options.insert(Options::ENABLE_STRIKETHROUGH);
22
23 let parser = Parser::new_ext(text, options);
24 let mut spans = Vec::new();
25 let mut style_stack: Vec<Modifier> = Vec::new();
26 let mut color_stack: Vec<Color> = Vec::new();
27 let mut link_url_stack: Vec<String> = Vec::new();
28
29 for event in parser {
30 match event {
31 Event::Start(Tag::Strong) => {
33 style_stack.push(theme.bold());
34 }
35 Event::Start(Tag::Emphasis) => {
36 style_stack.push(theme.italic());
37 }
38 Event::Start(Tag::Strikethrough) => {
39 style_stack.push(theme.strikethrough());
40 }
41
42 Event::End(TagEnd::Strong)
44 | Event::End(TagEnd::Emphasis)
45 | Event::End(TagEnd::Strikethrough) => {
46 style_stack.pop();
47 }
48
49 Event::Text(t) => {
51 let style = build_style(&style_stack, &color_stack);
52 spans.push(Span::styled(t.into_string(), style));
53 }
54
55 Event::Code(code) => {
57 spans.push(Span::styled(code.into_string(), theme.inline_code()));
58 }
59
60 Event::SoftBreak => {
62 spans.push(Span::raw(" "));
63 }
64
65 Event::HardBreak => {
67 spans.push(Span::raw("\n"));
68 }
69
70 Event::Start(Tag::Paragraph)
72 | Event::End(TagEnd::Paragraph)
73 | Event::Start(Tag::Heading { .. })
74 | Event::End(TagEnd::Heading(_)) => {}
75
76 Event::Start(Tag::Link { dest_url, .. }) => {
78 if let Some(color) = theme.link_text().fg {
80 color_stack.push(color);
81 }
82 link_url_stack.push(dest_url.into_string());
84 }
85 Event::End(TagEnd::Link) => {
86 color_stack.pop();
87 if let Some(url) = link_url_stack.pop()
89 && !url.is_empty()
90 {
91 spans.push(Span::styled(format!(" ({})", url), theme.link_url()));
92 }
93 }
94
95 _ => {}
97 }
98 }
99
100 spans
101}
102
103fn build_style(modifiers: &[Modifier], colors: &[Color]) -> Style {
105 let mut style = Style::default();
106
107 for modifier in modifiers {
109 style = style.add_modifier(*modifier);
110 }
111
112 if let Some(&color) = colors.last() {
114 style = style.fg(color);
115 }
116
117 style
118}
119
120pub fn parse_to_styled_words(text: &str, theme: &Theme) -> Vec<(String, Style)> {
124 let spans = parse_to_spans(text, theme);
125 let mut words = Vec::new();
126
127 for span in spans {
128 let content = span.content.to_string();
129 let style = span.style;
130
131 for word in content.split_whitespace() {
133 words.push((word.to_string(), style));
134 }
135 }
136
137 words
138}
139
140pub enum ContentSegment {
142 Text(String),
143 Table(Vec<String>),
144 CodeBlock {
146 code: String,
147 #[allow(dead_code)]
150 language: Option<String>,
151 },
152}
153
154pub fn wrap_with_prefix(
158 text: &str,
159 first_prefix: &str,
160 first_prefix_style: Style,
161 cont_prefix: &str,
162 max_width: usize,
163 theme: &Theme,
164) -> Vec<Line<'static>> {
165 let mut lines = Vec::new();
166 let text_width = max_width.saturating_sub(first_prefix.chars().count());
167
168 if text_width == 0 || text.is_empty() {
169 let spans = parse_to_spans(text, theme);
171 let mut result_spans = vec![Span::styled(first_prefix.to_string(), first_prefix_style)];
172 result_spans.extend(spans);
173 return vec![Line::from(result_spans)];
174 }
175
176 let styled_words = parse_to_styled_words(text, theme);
178
179 let mut current_line_spans: Vec<Span<'static>> = Vec::new();
181 let mut current_line_len = 0usize;
182 let mut is_first_line = true;
183
184 for (word, style) in styled_words {
185 let word_len = word.chars().count();
186 let would_be_len = if current_line_len == 0 {
187 word_len
188 } else {
189 current_line_len + 1 + word_len
190 };
191
192 if would_be_len > text_width && current_line_len > 0 {
193 let prefix = if is_first_line {
195 first_prefix
196 } else {
197 cont_prefix
198 };
199 let prefix_style = if is_first_line {
200 first_prefix_style
201 } else {
202 Style::default()
203 };
204 let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
205 line_spans.append(&mut current_line_spans);
206 lines.push(Line::from(line_spans));
207
208 current_line_spans.push(Span::styled(word, style));
209 current_line_len = word_len;
210 is_first_line = false;
211 } else {
212 if current_line_len > 0 {
213 current_line_spans.push(Span::raw(" "));
214 current_line_len += 1;
215 }
216 current_line_spans.push(Span::styled(word, style));
217 current_line_len += word_len;
218 }
219 }
220
221 if !current_line_spans.is_empty() || is_first_line {
223 let prefix = if is_first_line {
224 first_prefix
225 } else {
226 cont_prefix
227 };
228 let prefix_style = if is_first_line {
229 first_prefix_style
230 } else {
231 Style::default()
232 };
233 let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
234 line_spans.extend(current_line_spans);
235 lines.push(Line::from(line_spans));
236 }
237
238 lines
239}
240
241pub fn detect_heading_level(text: &str) -> Option<u8> {
245 let parser = Parser::new(text);
246 for event in parser {
247 if let Event::Start(Tag::Heading { level, .. }) = event {
248 return Some(level as u8);
249 }
250 }
251 None
252}
253
254pub fn heading_style(level: u8, theme: &Theme) -> Style {
256 match level {
257 1 => theme.heading_1(),
258 2 => theme.heading_2(),
259 3 => theme.heading_3(),
260 _ => theme.heading_4(),
261 }
262}
263
264fn is_code_fence(line: &str) -> Option<&str> {
266 let trimmed = line.trim();
267 if trimmed.starts_with("```") {
268 Some(trimmed.strip_prefix("```").unwrap_or("").trim())
269 } else if trimmed.starts_with("~~~") {
270 Some(trimmed.strip_prefix("~~~").unwrap_or("").trim())
271 } else {
272 None
273 }
274}
275
276fn is_code_fence_end(line: &str) -> bool {
278 let trimmed = line.trim();
279 trimmed == "```" || trimmed == "~~~"
280}
281
282pub fn split_content_segments(content: &str) -> Vec<ContentSegment> {
284 let lines: Vec<&str> = content.lines().collect();
285 let mut segments = Vec::new();
286 let mut current_text = String::new();
287 let mut i = 0;
288
289 while i < lines.len() {
290 if let Some(lang) = is_code_fence(lines[i]) {
292 if !current_text.is_empty() {
294 segments.push(ContentSegment::Text(current_text));
295 current_text = String::new();
296 }
297
298 let language = if lang.is_empty() {
299 None
300 } else {
301 Some(lang.to_string())
302 };
303 i += 1; let mut code_content = String::new();
307 while i < lines.len() && !is_code_fence_end(lines[i]) {
308 if !code_content.is_empty() {
309 code_content.push('\n');
310 }
311 code_content.push_str(lines[i]);
312 i += 1;
313 }
314
315 if i < lines.len() && is_code_fence_end(lines[i]) {
317 i += 1;
318 }
319
320 segments.push(ContentSegment::CodeBlock {
321 code: code_content,
322 language,
323 });
324 }
325 else if is_table_line(lines[i]) && i + 1 < lines.len() && is_table_separator(lines[i + 1])
327 {
328 if !current_text.is_empty() {
330 segments.push(ContentSegment::Text(current_text));
331 current_text = String::new();
332 }
333
334 let mut table_lines = Vec::new();
336 while i < lines.len() && is_table_line(lines[i]) {
337 table_lines.push(lines[i].to_string());
338 i += 1;
339 }
340 segments.push(ContentSegment::Table(table_lines));
341 } else {
342 if !current_text.is_empty() {
344 current_text.push('\n');
345 }
346 current_text.push_str(lines[i]);
347 i += 1;
348 }
349 }
350
351 if !current_text.is_empty() {
353 segments.push(ContentSegment::Text(current_text));
354 }
355
356 segments
357}
358
359pub fn render_markdown_with_prefix(
361 content: &str,
362 max_width: usize,
363 theme: &Theme,
364) -> Vec<Line<'static>> {
365 let segments = split_content_segments(content);
366
367 let mut all_lines = Vec::new();
368 let mut is_first_line = true;
369
370 for segment in segments {
371 match segment {
372 ContentSegment::Text(text) => {
373 for line in text.lines() {
375 let line = line.trim();
376 if line.is_empty() {
377 all_lines.push(Line::from(""));
379 continue;
380 }
381
382 if let Some(level) = detect_heading_level(line) {
384 let heading_text = line.trim_start_matches('#').trim();
385 let base_style = heading_style(level, theme);
386 let prefix = if is_first_line {
387 ASSISTANT_PREFIX
388 } else {
389 CONTINUATION
390 };
391 let prefix_style = if is_first_line {
392 theme.assistant_prefix()
393 } else {
394 Style::default()
395 };
396
397 let parsed_spans = parse_to_spans(heading_text, theme);
399 let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
400
401 if parsed_spans.is_empty() {
402 line_spans.push(Span::styled(heading_text.to_string(), base_style));
403 } else {
404 for span in parsed_spans {
405 let merged_style = base_style.patch(span.style);
407 line_spans
408 .push(Span::styled(span.content.to_string(), merged_style));
409 }
410 }
411
412 all_lines.push(Line::from(line_spans));
413 is_first_line = false;
414 continue;
415 }
416
417 let prefix = if is_first_line {
419 ASSISTANT_PREFIX
420 } else {
421 CONTINUATION
422 };
423 let prefix_style = if is_first_line {
424 theme.assistant_prefix()
425 } else {
426 Style::default()
427 };
428
429 let lines = wrap_with_prefix(
430 line,
431 prefix,
432 prefix_style,
433 CONTINUATION,
434 max_width,
435 theme,
436 );
437 all_lines.extend(lines);
438 is_first_line = false;
439 }
440 }
441 ContentSegment::Table(table_lines) => {
442 let lines = render_table(&table_lines, theme);
443 all_lines.extend(lines);
444 is_first_line = false;
445 }
446 ContentSegment::CodeBlock { code, language: _ } => {
447 let lines = render_code_block(&code, is_first_line, theme);
448 all_lines.extend(lines);
449 is_first_line = false;
450 }
451 }
452 }
453 all_lines
454}
455
456fn render_code_block(code: &str, is_first_line: bool, theme: &Theme) -> Vec<Line<'static>> {
458 const CODE_INDENT: &str = " "; let code_style = theme.code_block();
460 let prefix_style = theme.assistant_prefix();
461
462 let mut lines = Vec::new();
463
464 if !is_first_line {
466 lines.push(Line::from(""));
467 }
468
469 for (i, line) in code.lines().enumerate() {
470 let mut spans = Vec::new();
471
472 if i == 0 && is_first_line {
474 spans.push(Span::styled(ASSISTANT_PREFIX, prefix_style));
475 } else {
476 spans.push(Span::raw(CONTINUATION));
477 }
478
479 spans.push(Span::styled(format!("{}{}", CODE_INDENT, line), code_style));
481
482 lines.push(Line::from(spans));
483 }
484
485 lines.push(Line::from(""));
487
488 lines
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn test_plain_text() {
497 let theme = Theme::default();
498 let spans = parse_to_spans("hello world", &theme);
499 assert_eq!(spans.len(), 1);
500 assert_eq!(spans[0].content, "hello world");
501 }
502
503 #[test]
504 fn test_bold() {
505 let theme = Theme::default();
506 let spans = parse_to_spans("**bold**", &theme);
507 assert_eq!(spans.len(), 1);
508 assert_eq!(spans[0].content, "bold");
509 assert!(spans[0].style.add_modifier == Modifier::BOLD.into());
510 }
511
512 #[test]
513 fn test_italic() {
514 let theme = Theme::default();
515 let spans = parse_to_spans("*italic*", &theme);
516 assert_eq!(spans.len(), 1);
517 assert_eq!(spans[0].content, "italic");
518 }
519
520 #[test]
521 fn test_mixed_formatting() {
522 let theme = Theme::default();
523 let spans = parse_to_spans("normal **bold** and *italic*", &theme);
524 assert!(spans.len() >= 3);
525 }
526
527 #[test]
528 fn test_inline_code() {
529 let theme = Theme::default();
530 let spans = parse_to_spans("use `code` here", &theme);
531 assert!(spans.iter().any(|s| s.content == "code"));
532 }
533
534 #[test]
535 fn test_styled_words() {
536 let theme = Theme::default();
537 let words = parse_to_styled_words("hello **bold** world", &theme);
538 assert_eq!(words.len(), 3);
539 assert_eq!(words[0].0, "hello");
540 assert_eq!(words[1].0, "bold");
541 assert_eq!(words[2].0, "world");
542 }
543
544 #[test]
545 fn test_entirely_bold_line() {
546 let theme = Theme::default();
547 let input = "**The Midnight Adventure**";
548 let spans = parse_to_spans(input, &theme);
549
550 assert!(!spans.is_empty(), "Should have at least one span");
551 assert!(
552 spans[0].style.add_modifier.contains(Modifier::BOLD),
553 "First span should be bold"
554 );
555 }
556
557 #[test]
558 fn test_link_parsing() {
559 let theme = Theme::default();
560 let input = "[The Rust Book](https://doc.rust-lang.org/book/)";
561 let spans = parse_to_spans(input, &theme);
562
563 assert!(
564 spans.iter().any(|s| s.content.contains("Rust Book")),
565 "Should contain link text"
566 );
567 assert!(
568 spans
569 .iter()
570 .any(|s| s.content.contains("doc.rust-lang.org")),
571 "Should contain URL"
572 );
573 }
574
575 #[test]
576 fn test_heading_detection() {
577 assert_eq!(detect_heading_level("# Heading 1"), Some(1));
579 assert_eq!(detect_heading_level("## Heading 2"), Some(2));
580 assert_eq!(detect_heading_level("### Heading 3"), Some(3));
581 assert_eq!(detect_heading_level("###### Heading 6"), Some(6));
582 assert_eq!(detect_heading_level("# "), Some(1)); assert_eq!(detect_heading_level("Not a heading"), None);
586 assert_eq!(detect_heading_level("#NoSpace"), None); assert_eq!(detect_heading_level("####### Too many"), None); }
589
590 #[test]
591 fn test_render_markdown_with_indented_link() {
592 let theme = Theme::default();
593 let content = "Here is a link:\n [The Rust Book](https://doc.rust-lang.org/book/)";
594 let lines = render_markdown_with_prefix(content, 80, &theme);
595
596 let all_text: String = lines
597 .iter()
598 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
599 .collect();
600
601 assert!(
602 all_text.contains("The Rust Book"),
603 "Should contain link text"
604 );
605 assert!(
606 !all_text.contains("](https://"),
607 "URL should not appear in literal markdown syntax"
608 );
609 }
610
611 #[test]
612 fn test_styled_words_bold() {
613 let theme = Theme::default();
614 let words = parse_to_styled_words("**The Midnight Adventure**", &theme);
615 assert_eq!(words.len(), 3);
616 for (word, style) in &words {
618 assert!(
619 style.add_modifier.contains(Modifier::BOLD),
620 "Word {:?} should be bold",
621 word
622 );
623 }
624 }
625
626 #[test]
627 fn test_code_block_detection() {
628 let content = "Some text\n```go\nfunc main() {\n println(\"hello\")\n}\n```\nMore text";
629 let segments = split_content_segments(content);
630
631 assert_eq!(segments.len(), 3);
632
633 match &segments[0] {
635 ContentSegment::Text(t) => assert_eq!(t, "Some text"),
636 _ => panic!("Expected Text segment"),
637 }
638
639 match &segments[1] {
641 ContentSegment::CodeBlock { code, language } => {
642 assert_eq!(language.as_deref(), Some("go"));
643 assert!(code.contains("func main()"));
644 assert!(code.contains("println"));
645 }
646 _ => panic!("Expected CodeBlock segment"),
647 }
648
649 match &segments[2] {
651 ContentSegment::Text(t) => assert_eq!(t, "More text"),
652 _ => panic!("Expected Text segment"),
653 }
654 }
655
656 #[test]
657 fn test_code_block_no_language() {
658 let content = "```\ncode here\n```";
659 let segments = split_content_segments(content);
660
661 assert_eq!(segments.len(), 1);
662 match &segments[0] {
663 ContentSegment::CodeBlock { code, language } => {
664 assert!(language.is_none());
665 assert_eq!(code, "code here");
666 }
667 _ => panic!("Expected CodeBlock segment"),
668 }
669 }
670}