1use crate::theme;
2use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd};
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span};
5use std::borrow::Cow;
6
7pub fn render_markdown(text: &str) -> Vec<Line<'static>> {
10 let cleaned = preprocess_markdown(text);
11 let mut renderer = MarkdownRenderer::new();
12 let opts =
13 Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_HEADING_ATTRIBUTES;
14 let parser = Parser::new_ext(&cleaned, opts);
15
16 for event in parser {
17 renderer.process(event);
18 }
19 renderer.finish()
20}
21
22fn preprocess_markdown(text: &str) -> String {
28 let mut result = String::with_capacity(text.len() + 64);
29 let chars: Vec<char> = text.chars().collect();
30 let len = chars.len();
31 let mut i = 0;
32
33 while i < len {
34 if i + 3 < len && chars[i].is_ascii_digit() && chars[i + 1] == '.' && chars[i + 2] == ' ' {
37 let prev_is_text = i > 0 && chars[i - 1] != '\n';
39 let next_is_item = i + 3 < len
41 && (chars[i + 3].is_uppercase()
42 || chars[i + 3] == '`'
43 || chars[i + 3] == '*'
44 || chars[i + 3] == '[');
45 if prev_is_text && next_is_item {
46 result.push('\n');
47 }
48 }
49
50 if i + 1 < len
52 && (chars[i] == '.' || chars[i] == '!' || chars[i] == '?')
53 && chars[i + 1].is_uppercase()
54 {
55 let is_abbrev = i > 0 && chars[i - 1].is_uppercase();
57 if !is_abbrev {
58 result.push(chars[i]);
59 result.push_str("\n\n");
60 i += 1;
61 continue;
62 }
63 }
64
65 result.push(chars[i]);
66 i += 1;
67 }
68
69 result
70}
71
72const INDENT: &str = " ";
73const MIN_COL_WIDTH: usize = 3;
74const MAX_COL_WIDTH: usize = 40;
75
76struct MarkdownRenderer {
77 lines: Vec<Line<'static>>,
78 spans: Vec<Span<'static>>,
80 style_stack: Vec<Style>,
82 heading_level: u8,
84 list_stack: Vec<Option<u64>>,
86 list_item_start: bool,
88 table: Option<TableState>,
90 in_code_block: bool,
92 code_lang: Option<String>,
94 code_lines: Vec<String>,
96 link_url: Option<String>,
98}
99
100struct TableState {
101 alignments: Vec<Alignment>,
102 header_row: Vec<String>,
103 body_rows: Vec<Vec<String>>,
104 current_row: Vec<String>,
105 current_cell: String,
106 in_header: bool,
107}
108
109impl MarkdownRenderer {
110 fn new() -> Self {
111 Self {
112 lines: Vec::new(),
113 spans: Vec::new(),
114 style_stack: vec![Style::default().fg(theme::FROST)],
115 heading_level: 0,
116 list_stack: Vec::new(),
117 list_item_start: false,
118 table: None,
119 in_code_block: false,
120 code_lang: None,
121 code_lines: Vec::new(),
122 link_url: None,
123 }
124 }
125
126 fn current_style(&self) -> Style {
127 self.style_stack.last().copied().unwrap_or(theme::text())
128 }
129
130 fn list_indent(&self) -> String {
131 let depth = self.list_stack.len().saturating_sub(1);
132 format!("{}{}", INDENT, " ".repeat(depth))
133 }
134
135 fn process(&mut self, event: Event<'_>) {
136 match event {
137 Event::Start(tag) => self.start_tag(tag),
138 Event::End(tag) => self.end_tag(tag),
139 Event::Text(text) => self.text(&text),
140 Event::Code(code) => self.inline_code(&code),
141 Event::SoftBreak => self.soft_break(),
142 Event::HardBreak => self.hard_break(),
143 Event::Rule => self.rule(),
144 _ => {}
145 }
146 }
147
148 fn start_tag(&mut self, tag: Tag<'_>) {
149 match tag {
150 Tag::Heading { level, .. } => {
151 self.heading_level = level as u8;
152 let style = match level {
153 pulldown_cmark::HeadingLevel::H1 | pulldown_cmark::HeadingLevel::H2 => {
154 Style::default()
155 .fg(theme::HONEY)
156 .add_modifier(Modifier::BOLD)
157 }
158 pulldown_cmark::HeadingLevel::H3 => Style::default()
159 .fg(theme::FROST)
160 .add_modifier(Modifier::BOLD),
161 _ => Style::default().fg(theme::ICE).add_modifier(Modifier::BOLD),
162 };
163 self.style_stack.push(style);
164 }
165 Tag::Paragraph => {}
166 Tag::Emphasis => {
167 let base = self.current_style();
168 self.style_stack.push(base.add_modifier(Modifier::ITALIC));
169 }
170 Tag::Strong => {
171 let base = self.current_style();
172 self.style_stack.push(base.add_modifier(Modifier::BOLD));
173 }
174 Tag::List(start) => {
175 self.list_stack.push(start);
176 }
177 Tag::Item => {
178 self.list_item_start = true;
179 }
180 Tag::CodeBlock(kind) => {
181 self.in_code_block = true;
182 self.code_lines.clear();
183 self.code_lang = match kind {
184 pulldown_cmark::CodeBlockKind::Fenced(lang) => {
185 let l = lang.to_string();
186 if l.is_empty() { None } else { Some(l) }
187 }
188 _ => None,
189 };
190 }
191 Tag::Table(alignments) => {
192 self.table = Some(TableState {
193 alignments,
194 header_row: Vec::new(),
195 body_rows: Vec::new(),
196 current_row: Vec::new(),
197 current_cell: String::new(),
198 in_header: false,
199 });
200 }
201 Tag::TableHead => {
202 if let Some(ref mut t) = self.table {
203 t.in_header = true;
204 t.current_row.clear();
205 }
206 }
207 Tag::TableRow => {
208 if let Some(ref mut t) = self.table {
209 t.current_row.clear();
210 }
211 }
212 Tag::TableCell => {
213 if let Some(ref mut t) = self.table {
214 t.current_cell.clear();
215 }
216 }
217 Tag::Link { dest_url, .. } => {
218 self.link_url = Some(dest_url.to_string());
219 }
220 _ => {}
221 }
222 }
223
224 fn end_tag(&mut self, tag: TagEnd) {
225 match tag {
226 TagEnd::Heading(_) => {
227 self.style_stack.pop();
228 self.lines.push(Line::from(""));
229 self.flush_spans();
230 self.heading_level = 0;
231 }
232 TagEnd::Paragraph => {
233 self.flush_spans();
234 self.lines.push(Line::from(""));
235 }
236 TagEnd::Emphasis | TagEnd::Strong => {
237 self.style_stack.pop();
238 }
239 TagEnd::List(_) => {
240 self.list_stack.pop();
241 if self.list_stack.is_empty() {
242 self.lines.push(Line::from(""));
243 }
244 }
245 TagEnd::Item => {
246 self.flush_spans();
247 }
248 TagEnd::CodeBlock => {
249 self.in_code_block = false;
250 self.emit_code_block();
251 }
252 TagEnd::Table => {
253 self.emit_table();
254 }
255 TagEnd::TableHead => {
256 if let Some(ref mut t) = self.table {
257 t.header_row = std::mem::take(&mut t.current_row);
258 t.in_header = false;
259 }
260 }
261 TagEnd::TableRow => {
262 if let Some(ref mut t) = self.table {
263 let row = std::mem::take(&mut t.current_row);
264 t.body_rows.push(row);
265 }
266 }
267 TagEnd::TableCell => {
268 if let Some(ref mut t) = self.table {
269 let cell = std::mem::take(&mut t.current_cell);
270 t.current_row.push(cell);
271 }
272 }
273 TagEnd::Link => {
274 if let Some(url) = self.link_url.take()
275 && !url.is_empty()
276 {
277 self.spans
278 .push(Span::styled(format!(" ({})", url), theme::muted()));
279 }
280 }
281 _ => {}
282 }
283 }
284
285 fn text(&mut self, text: &str) {
286 if let Some(ref mut t) = self.table {
288 t.current_cell.push_str(text);
289 return;
290 }
291
292 if self.in_code_block {
294 self.code_lines.extend(text.lines().map(String::from));
295 if text.ends_with('\n') && self.code_lines.last().is_some_and(|l| l.is_empty()) {
296 self.code_lines.pop();
297 }
298 return;
299 }
300
301 if self.link_url.is_some() {
303 self.spans.push(Span::styled(
304 text.to_string(),
305 Style::default().fg(theme::HONEY),
306 ));
307 return;
308 }
309
310 if self.list_item_start {
312 self.list_item_start = false;
313 let indent = self.list_indent();
314 match self.list_stack.last_mut() {
315 Some(Some(n)) => {
316 let prefix = format!("{}{}. ", indent, n);
317 *n += 1;
318 self.spans
319 .push(Span::styled(prefix, Style::default().fg(theme::HONEY)));
320 }
321 _ => {
322 let prefix = format!("{}\u{2022} ", indent);
323 self.spans
324 .push(Span::styled(prefix, Style::default().fg(theme::HONEY)));
325 }
326 }
327 }
328
329 let style = self.current_style();
330 self.spans.push(Span::styled(text.to_string(), style));
331 }
332
333 fn inline_code(&mut self, code: &str) {
334 if let Some(ref mut t) = self.table {
336 t.current_cell.push_str(code);
337 return;
338 }
339
340 self.spans.push(Span::styled(
341 format!("`{}`", code),
342 Style::default().fg(theme::POLLEN),
343 ));
344 }
345
346 fn soft_break(&mut self) {
347 let style = self.current_style();
350 self.spans.push(Span::styled(" ", style));
351 }
352
353 fn hard_break(&mut self) {
354 self.flush_spans();
355 }
356
357 fn rule(&mut self) {
358 self.lines.push(Line::from(Span::styled(
359 format!("{}────────────────────────────────────────", INDENT),
360 Style::default().fg(theme::STEEL),
361 )));
362 self.lines.push(Line::from(""));
363 }
364
365 fn flush_spans(&mut self) {
367 if self.spans.is_empty() {
368 return;
369 }
370 let mut all_spans = vec![Span::raw(INDENT.to_string())];
371 all_spans.append(&mut self.spans);
372 self.lines.push(Line::from(all_spans));
373 }
374
375 fn emit_code_block(&mut self) {
376 let lang_label = self.code_lang.take();
377 let border_style = Style::default().fg(theme::STEEL);
378 let code_style = Style::default().fg(theme::SLATE);
379
380 let top = if let Some(ref lang) = lang_label {
381 format!(
382 "{}\u{250c}\u{2500}\u{2500} {} \u{2500}\u{2500}",
383 INDENT, lang
384 )
385 } else {
386 format!("{}\u{250c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", INDENT)
387 };
388 self.lines.push(Line::from(Span::styled(top, border_style)));
389
390 for line in &self.code_lines {
391 self.lines.push(Line::from(vec![
392 Span::styled(format!("{}\u{2502} ", INDENT), border_style),
393 Span::styled(line.to_string(), code_style),
394 ]));
395 }
396
397 self.lines.push(Line::from(Span::styled(
398 format!("{}\u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", INDENT),
399 border_style,
400 )));
401 self.lines.push(Line::from(""));
402 self.code_lines.clear();
403 }
404
405 fn emit_table(&mut self) {
406 let table = match self.table.take() {
407 Some(t) => t,
408 None => return,
409 };
410
411 let num_cols = table.alignments.len().max(
412 table
413 .header_row
414 .len()
415 .max(table.body_rows.first().map_or(0, |r| r.len())),
416 );
417 if num_cols == 0 {
418 return;
419 }
420
421 let mut widths: Vec<usize> = vec![MIN_COL_WIDTH; num_cols];
422 for (i, cell) in table.header_row.iter().enumerate() {
423 if i < num_cols {
424 widths[i] = widths[i].max(cell.len()).min(MAX_COL_WIDTH);
425 }
426 }
427 for row in &table.body_rows {
428 for (i, cell) in row.iter().enumerate() {
429 if i < num_cols {
430 widths[i] = widths[i].max(cell.len()).min(MAX_COL_WIDTH);
431 }
432 }
433 }
434
435 let border_style = Style::default().fg(theme::STEEL);
436 let header_style = Style::default()
437 .fg(theme::FROST)
438 .add_modifier(Modifier::BOLD);
439 let cell_style = Style::default().fg(theme::FROST);
440
441 let top = format!(
442 "{}\u{250c}{}\u{2510}",
443 INDENT,
444 widths
445 .iter()
446 .map(|w| "\u{2500}".repeat(w + 2))
447 .collect::<Vec<_>>()
448 .join("\u{252c}")
449 );
450 self.lines.push(Line::from(Span::styled(top, border_style)));
451
452 if !table.header_row.is_empty() {
453 let mut spans = vec![Span::styled(format!("{}\u{2502}", INDENT), border_style)];
454 for (i, cell) in table.header_row.iter().enumerate() {
455 let w = widths.get(i).copied().unwrap_or(MIN_COL_WIDTH);
456 let content = fit_cell(cell, w, table.alignments.get(i));
457 spans.push(Span::styled(format!(" {} ", content), header_style));
458 spans.push(Span::styled("\u{2502}", border_style));
459 }
460 for i in table.header_row.len()..num_cols {
461 let w = widths.get(i).copied().unwrap_or(MIN_COL_WIDTH);
462 spans.push(Span::styled(" ".repeat(w + 2), header_style));
463 spans.push(Span::styled("\u{2502}", border_style));
464 }
465 self.lines.push(Line::from(spans));
466
467 let sep = format!(
468 "{}\u{251c}{}\u{2524}",
469 INDENT,
470 widths
471 .iter()
472 .map(|w| "\u{2500}".repeat(w + 2))
473 .collect::<Vec<_>>()
474 .join("\u{253c}")
475 );
476 self.lines.push(Line::from(Span::styled(sep, border_style)));
477 }
478
479 for row in &table.body_rows {
480 let mut spans = vec![Span::styled(format!("{}\u{2502}", INDENT), border_style)];
481 for (i, cell) in row.iter().enumerate() {
482 let w = widths.get(i).copied().unwrap_or(MIN_COL_WIDTH);
483 let content = fit_cell(cell, w, table.alignments.get(i));
484 spans.push(Span::styled(format!(" {} ", content), cell_style));
485 spans.push(Span::styled("\u{2502}", border_style));
486 }
487 for i in row.len()..num_cols {
488 let w = widths.get(i).copied().unwrap_or(MIN_COL_WIDTH);
489 spans.push(Span::styled(" ".repeat(w + 2), cell_style));
490 spans.push(Span::styled("\u{2502}", border_style));
491 }
492 self.lines.push(Line::from(spans));
493 }
494
495 let bot = format!(
496 "{}\u{2514}{}\u{2518}",
497 INDENT,
498 widths
499 .iter()
500 .map(|w| "\u{2500}".repeat(w + 2))
501 .collect::<Vec<_>>()
502 .join("\u{2534}")
503 );
504 self.lines.push(Line::from(Span::styled(bot, border_style)));
505 self.lines.push(Line::from(""));
506 }
507
508 fn finish(mut self) -> Vec<Line<'static>> {
509 self.flush_spans();
510 self.lines
511 }
512}
513
514fn fit_cell(text: &str, width: usize, alignment: Option<&Alignment>) -> Cow<'static, str> {
516 let text = text.trim();
517 let len = text.len();
518
519 if len > width {
520 let truncated = if width > 3 {
521 format!("{}...", &text[..width - 3])
522 } else {
523 text[..width].to_string()
524 };
525 return Cow::Owned(truncated);
526 }
527
528 let padding = width - len;
529 match alignment.unwrap_or(&Alignment::None) {
530 Alignment::Right => Cow::Owned(format!("{}{}", " ".repeat(padding), text)),
531 Alignment::Center => {
532 let left = padding / 2;
533 let right = padding - left;
534 Cow::Owned(format!("{}{}{}", " ".repeat(left), text, " ".repeat(right)))
535 }
536 _ => Cow::Owned(format!("{}{}", text, " ".repeat(padding))),
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn plain_text_passthrough() {
546 let lines = render_markdown("Hello world");
547 assert!(!lines.is_empty());
548 let text: String = lines
549 .iter()
550 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
551 .collect();
552 assert!(text.contains("Hello world"));
553 }
554
555 #[test]
556 fn bold_text() {
557 let lines = render_markdown("**bold**");
558 let has_bold = lines.iter().any(|l| {
559 l.spans.iter().any(|s| {
560 s.style.add_modifier.contains(Modifier::BOLD) && s.content.contains("bold")
561 })
562 });
563 assert!(has_bold, "Expected bold styled span");
564 }
565
566 #[test]
567 fn inline_code() {
568 let lines = render_markdown("use `foo` here");
569 let has_code = lines.iter().any(|l| {
570 l.spans
571 .iter()
572 .any(|s| s.style.fg == Some(theme::POLLEN) && s.content.contains("`foo`"))
573 });
574 assert!(has_code, "Expected inline code span with POLLEN color");
575 }
576
577 #[test]
578 fn code_block() {
579 let lines = render_markdown("```rust\nfn main() {}\n```");
580 let text: String = lines
581 .iter()
582 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
583 .collect();
584 assert!(text.contains("rust"), "Expected language label");
585 assert!(text.contains("fn main()"), "Expected code content");
586 }
587
588 #[test]
589 fn heading_h1() {
590 let lines = render_markdown("# Title");
591 let has_honey = lines.iter().any(|l| {
592 l.spans
593 .iter()
594 .any(|s| s.style.fg == Some(theme::HONEY) && s.content.contains("Title"))
595 });
596 assert!(has_honey, "Expected H1 with HONEY color");
597 }
598
599 #[test]
600 fn unordered_list() {
601 let lines = render_markdown("- item one\n- item two");
602 let text: String = lines
603 .iter()
604 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
605 .collect();
606 assert!(text.contains("\u{2022}"), "Expected bullet character");
607 assert!(text.contains("item one"));
608 assert!(text.contains("item two"));
609 }
610
611 #[test]
612 fn fit_cell_truncates() {
613 let result = fit_cell("very long text", 8, None);
614 assert_eq!(result.as_ref(), "very ...");
615 }
616
617 #[test]
618 fn fit_cell_right_align() {
619 let result = fit_cell("hi", 5, Some(&Alignment::Right));
620 assert_eq!(result.as_ref(), " hi");
621 }
622
623 #[test]
624 fn empty_input() {
625 let lines = render_markdown("");
626 let text: String = lines
627 .iter()
628 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
629 .collect();
630 assert!(text.trim().is_empty());
631 }
632}