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 if !url.is_empty() {
90 spans.push(Span::styled(format!(" ({})", url), theme.link_url()));
91 }
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)]
148 language: Option<String>,
149 },
150}
151
152pub fn wrap_with_prefix(
156 text: &str,
157 first_prefix: &str,
158 first_prefix_style: Style,
159 cont_prefix: &str,
160 max_width: usize,
161 theme: &Theme,
162) -> Vec<Line<'static>> {
163 let mut lines = Vec::new();
164 let text_width = max_width.saturating_sub(first_prefix.chars().count());
165
166 if text_width == 0 || text.is_empty() {
167 let spans = parse_to_spans(text, theme);
169 let mut result_spans = vec![Span::styled(first_prefix.to_string(), first_prefix_style)];
170 result_spans.extend(spans);
171 return vec![Line::from(result_spans)];
172 }
173
174 let styled_words = parse_to_styled_words(text, theme);
176
177 let mut current_line_spans: Vec<Span<'static>> = Vec::new();
179 let mut current_line_len = 0usize;
180 let mut is_first_line = true;
181
182 for (word, style) in styled_words {
183 let word_len = word.chars().count();
184 let would_be_len = if current_line_len == 0 {
185 word_len
186 } else {
187 current_line_len + 1 + word_len
188 };
189
190 if would_be_len > text_width && current_line_len > 0 {
191 let prefix = if is_first_line { first_prefix } else { cont_prefix };
193 let prefix_style = if is_first_line { first_prefix_style } else { Style::default() };
194 let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
195 line_spans.extend(current_line_spans.drain(..));
196 lines.push(Line::from(line_spans));
197
198 current_line_spans.push(Span::styled(word, style));
199 current_line_len = word_len;
200 is_first_line = false;
201 } else {
202 if current_line_len > 0 {
203 current_line_spans.push(Span::raw(" "));
204 current_line_len += 1;
205 }
206 current_line_spans.push(Span::styled(word, style));
207 current_line_len += word_len;
208 }
209 }
210
211 if !current_line_spans.is_empty() || is_first_line {
213 let prefix = if is_first_line { first_prefix } else { cont_prefix };
214 let prefix_style = if is_first_line { first_prefix_style } else { Style::default() };
215 let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
216 line_spans.extend(current_line_spans);
217 lines.push(Line::from(line_spans));
218 }
219
220 lines
221}
222
223pub fn detect_heading_level(text: &str) -> Option<u8> {
227 let parser = Parser::new(text);
228 for event in parser {
229 if let Event::Start(Tag::Heading { level, .. }) = event {
230 return Some(level as u8);
231 }
232 }
233 None
234}
235
236pub fn heading_style(level: u8, theme: &Theme) -> Style {
238 match level {
239 1 => theme.heading_1(),
240 2 => theme.heading_2(),
241 3 => theme.heading_3(),
242 _ => theme.heading_4(),
243 }
244}
245
246fn is_code_fence(line: &str) -> Option<&str> {
248 let trimmed = line.trim();
249 if trimmed.starts_with("```") {
250 Some(trimmed.strip_prefix("```").unwrap_or("").trim())
251 } else if trimmed.starts_with("~~~") {
252 Some(trimmed.strip_prefix("~~~").unwrap_or("").trim())
253 } else {
254 None
255 }
256}
257
258fn is_code_fence_end(line: &str) -> bool {
260 let trimmed = line.trim();
261 trimmed == "```" || trimmed == "~~~"
262}
263
264pub fn split_content_segments(content: &str) -> Vec<ContentSegment> {
266 let lines: Vec<&str> = content.lines().collect();
267 let mut segments = Vec::new();
268 let mut current_text = String::new();
269 let mut i = 0;
270
271 while i < lines.len() {
272 if let Some(lang) = is_code_fence(lines[i]) {
274 if !current_text.is_empty() {
276 segments.push(ContentSegment::Text(current_text));
277 current_text = String::new();
278 }
279
280 let language = if lang.is_empty() { None } else { Some(lang.to_string()) };
281 i += 1; let mut code_content = String::new();
285 while i < lines.len() && !is_code_fence_end(lines[i]) {
286 if !code_content.is_empty() {
287 code_content.push('\n');
288 }
289 code_content.push_str(lines[i]);
290 i += 1;
291 }
292
293 if i < lines.len() && is_code_fence_end(lines[i]) {
295 i += 1;
296 }
297
298 segments.push(ContentSegment::CodeBlock { code: code_content, language });
299 }
300 else if is_table_line(lines[i]) && i + 1 < lines.len() && is_table_separator(lines[i + 1]) {
302 if !current_text.is_empty() {
304 segments.push(ContentSegment::Text(current_text));
305 current_text = String::new();
306 }
307
308 let mut table_lines = Vec::new();
310 while i < lines.len() && is_table_line(lines[i]) {
311 table_lines.push(lines[i].to_string());
312 i += 1;
313 }
314 segments.push(ContentSegment::Table(table_lines));
315 } else {
316 if !current_text.is_empty() {
318 current_text.push('\n');
319 }
320 current_text.push_str(lines[i]);
321 i += 1;
322 }
323 }
324
325 if !current_text.is_empty() {
327 segments.push(ContentSegment::Text(current_text));
328 }
329
330 segments
331}
332
333pub fn render_markdown_with_prefix(content: &str, max_width: usize, theme: &Theme) -> Vec<Line<'static>> {
335 let segments = split_content_segments(content);
336
337 let mut all_lines = Vec::new();
338 let mut is_first_line = true;
339
340 for segment in segments {
341 match segment {
342 ContentSegment::Text(text) => {
343 for line in text.lines() {
345 let line = line.trim();
346 if line.is_empty() {
347 all_lines.push(Line::from(""));
349 continue;
350 }
351
352 if let Some(level) = detect_heading_level(line) {
354 let heading_text = line.trim_start_matches('#').trim();
355 let base_style = heading_style(level, theme);
356 let prefix = if is_first_line { ASSISTANT_PREFIX } else { CONTINUATION };
357 let prefix_style = if is_first_line {
358 theme.assistant_prefix()
359 } else {
360 Style::default()
361 };
362
363 let parsed_spans = parse_to_spans(heading_text, theme);
365 let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
366
367 if parsed_spans.is_empty() {
368 line_spans.push(Span::styled(heading_text.to_string(), base_style));
369 } else {
370 for span in parsed_spans {
371 let merged_style = base_style.patch(span.style);
373 line_spans.push(Span::styled(span.content.to_string(), merged_style));
374 }
375 }
376
377 all_lines.push(Line::from(line_spans));
378 is_first_line = false;
379 continue;
380 }
381
382 let prefix = if is_first_line { ASSISTANT_PREFIX } else { CONTINUATION };
384 let prefix_style = if is_first_line {
385 theme.assistant_prefix()
386 } else {
387 Style::default()
388 };
389
390 let lines = wrap_with_prefix(
391 line,
392 prefix,
393 prefix_style,
394 CONTINUATION,
395 max_width,
396 theme,
397 );
398 all_lines.extend(lines);
399 is_first_line = false;
400 }
401 }
402 ContentSegment::Table(table_lines) => {
403 let lines = render_table(&table_lines, theme);
404 all_lines.extend(lines);
405 is_first_line = false;
406 }
407 ContentSegment::CodeBlock { code, language: _ } => {
408 let lines = render_code_block(&code, is_first_line, theme);
409 all_lines.extend(lines);
410 is_first_line = false;
411 }
412 }
413 }
414 all_lines
415}
416
417fn render_code_block(code: &str, is_first_line: bool, theme: &Theme) -> Vec<Line<'static>> {
419 const CODE_INDENT: &str = " "; let code_style = theme.code_block();
421 let prefix_style = theme.assistant_prefix();
422
423 let mut lines = Vec::new();
424
425 if !is_first_line {
427 lines.push(Line::from(""));
428 }
429
430 for (i, line) in code.lines().enumerate() {
431 let mut spans = Vec::new();
432
433 if i == 0 && is_first_line {
435 spans.push(Span::styled(ASSISTANT_PREFIX, prefix_style));
436 } else {
437 spans.push(Span::raw(CONTINUATION));
438 }
439
440 spans.push(Span::styled(format!("{}{}", CODE_INDENT, line), code_style));
442
443 lines.push(Line::from(spans));
444 }
445
446 lines.push(Line::from(""));
448
449 lines
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn test_plain_text() {
458 let theme = Theme::default();
459 let spans = parse_to_spans("hello world", &theme);
460 assert_eq!(spans.len(), 1);
461 assert_eq!(spans[0].content, "hello world");
462 }
463
464 #[test]
465 fn test_bold() {
466 let theme = Theme::default();
467 let spans = parse_to_spans("**bold**", &theme);
468 assert_eq!(spans.len(), 1);
469 assert_eq!(spans[0].content, "bold");
470 assert!(spans[0].style.add_modifier == Modifier::BOLD.into());
471 }
472
473 #[test]
474 fn test_italic() {
475 let theme = Theme::default();
476 let spans = parse_to_spans("*italic*", &theme);
477 assert_eq!(spans.len(), 1);
478 assert_eq!(spans[0].content, "italic");
479 }
480
481 #[test]
482 fn test_mixed_formatting() {
483 let theme = Theme::default();
484 let spans = parse_to_spans("normal **bold** and *italic*", &theme);
485 assert!(spans.len() >= 3);
486 }
487
488 #[test]
489 fn test_inline_code() {
490 let theme = Theme::default();
491 let spans = parse_to_spans("use `code` here", &theme);
492 assert!(spans.iter().any(|s| s.content == "code"));
493 }
494
495 #[test]
496 fn test_styled_words() {
497 let theme = Theme::default();
498 let words = parse_to_styled_words("hello **bold** world", &theme);
499 assert_eq!(words.len(), 3);
500 assert_eq!(words[0].0, "hello");
501 assert_eq!(words[1].0, "bold");
502 assert_eq!(words[2].0, "world");
503 }
504
505 #[test]
506 fn test_entirely_bold_line() {
507 let theme = Theme::default();
508 let input = "**The Midnight Adventure**";
509 let spans = parse_to_spans(input, &theme);
510
511 assert!(!spans.is_empty(), "Should have at least one span");
512 assert!(
513 spans[0].style.add_modifier.contains(Modifier::BOLD),
514 "First span should be bold"
515 );
516 }
517
518 #[test]
519 fn test_link_parsing() {
520 let theme = Theme::default();
521 let input = "[The Rust Book](https://doc.rust-lang.org/book/)";
522 let spans = parse_to_spans(input, &theme);
523
524 assert!(
525 spans.iter().any(|s| s.content.contains("Rust Book")),
526 "Should contain link text"
527 );
528 assert!(
529 spans.iter().any(|s| s.content.contains("doc.rust-lang.org")),
530 "Should contain URL"
531 );
532 }
533
534 #[test]
535 fn test_heading_detection() {
536 assert_eq!(detect_heading_level("# Heading 1"), Some(1));
538 assert_eq!(detect_heading_level("## Heading 2"), Some(2));
539 assert_eq!(detect_heading_level("### Heading 3"), Some(3));
540 assert_eq!(detect_heading_level("###### Heading 6"), Some(6));
541 assert_eq!(detect_heading_level("# "), Some(1)); assert_eq!(detect_heading_level("Not a heading"), None);
545 assert_eq!(detect_heading_level("#NoSpace"), None); assert_eq!(detect_heading_level("####### Too many"), None); }
548
549 #[test]
550 fn test_render_markdown_with_indented_link() {
551 let theme = Theme::default();
552 let content = "Here is a link:\n [The Rust Book](https://doc.rust-lang.org/book/)";
553 let lines = render_markdown_with_prefix(content, 80, &theme);
554
555 let all_text: String = lines
556 .iter()
557 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
558 .collect();
559
560 assert!(all_text.contains("The Rust Book"), "Should contain link text");
561 assert!(
562 !all_text.contains("](https://"),
563 "URL should not appear in literal markdown syntax"
564 );
565 }
566
567 #[test]
568 fn test_styled_words_bold() {
569 let theme = Theme::default();
570 let words = parse_to_styled_words("**The Midnight Adventure**", &theme);
571 assert_eq!(words.len(), 3);
572 for (word, style) in &words {
574 assert!(
575 style.add_modifier.contains(Modifier::BOLD),
576 "Word {:?} should be bold",
577 word
578 );
579 }
580 }
581
582 #[test]
583 fn test_code_block_detection() {
584 let content = "Some text\n```go\nfunc main() {\n println(\"hello\")\n}\n```\nMore text";
585 let segments = split_content_segments(content);
586
587 assert_eq!(segments.len(), 3);
588
589 match &segments[0] {
591 ContentSegment::Text(t) => assert_eq!(t, "Some text"),
592 _ => panic!("Expected Text segment"),
593 }
594
595 match &segments[1] {
597 ContentSegment::CodeBlock { code, language } => {
598 assert_eq!(language.as_deref(), Some("go"));
599 assert!(code.contains("func main()"));
600 assert!(code.contains("println"));
601 }
602 _ => panic!("Expected CodeBlock segment"),
603 }
604
605 match &segments[2] {
607 ContentSegment::Text(t) => assert_eq!(t, "More text"),
608 _ => panic!("Expected Text segment"),
609 }
610 }
611
612 #[test]
613 fn test_code_block_no_language() {
614 let content = "```\ncode here\n```";
615 let segments = split_content_segments(content);
616
617 assert_eq!(segments.len(), 1);
618 match &segments[0] {
619 ContentSegment::CodeBlock { code, language } => {
620 assert!(language.is_none());
621 assert_eq!(code, "code here");
622 }
623 _ => panic!("Expected CodeBlock segment"),
624 }
625 }
626}