Skip to main content

apiari_tui/
markdown.rs

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
7/// Render markdown text into styled ratatui lines.
8/// Each line is indented with 2 spaces to match the conversation layout.
9pub 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
22/// Pre-process LLM output to fix common formatting issues before markdown parsing.
23///
24/// Handles:
25/// - Inline numbered lists: "text 1. item 2. item" → "text\n1. item\n2. item"
26/// - Run-together sentences: "done.Next" → "done.\n\nNext" (period+capital, no space)
27fn 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        // Detect inline numbered list items: non-newline followed by "N. " where N is 1-9
35        // e.g., "...settings  2. Investigate" → "...settings\n2. Investigate"
36        if i + 3 < len && chars[i].is_ascii_digit() && chars[i + 1] == '.' && chars[i + 2] == ' ' {
37            // Check if preceded by text (not start-of-line)
38            let prev_is_text = i > 0 && chars[i - 1] != '\n';
39            // But only if this looks like a list item (next char after space is uppercase or backtick)
40            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        // Detect run-together sentences: ".Capital" or "!Capital" or "?Capital" with no space
51        if i + 1 < len
52            && (chars[i] == '.' || chars[i] == '!' || chars[i] == '?')
53            && chars[i + 1].is_uppercase()
54        {
55            // Don't trigger on abbreviations like "U.S.A" or ellipsis
56            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    /// Current inline spans being accumulated for a paragraph/heading/list item.
79    spans: Vec<Span<'static>>,
80    /// Style stack for nested inline formatting (bold, italic, code).
81    style_stack: Vec<Style>,
82    /// Current heading level (0 = not in heading).
83    heading_level: u8,
84    /// List nesting: each entry is Some(counter) for ordered, None for unordered.
85    list_stack: Vec<Option<u64>>,
86    /// Whether we're at the start of a list item (need to emit bullet/number).
87    list_item_start: bool,
88    /// Table state.
89    table: Option<TableState>,
90    /// Whether we're inside a code block.
91    in_code_block: bool,
92    /// Code block language label.
93    code_lang: Option<String>,
94    /// Accumulated code block lines.
95    code_lines: Vec<String>,
96    /// Whether we're inside a link — accumulate text, emit styled at end.
97    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        // Table cell text
287        if let Some(ref mut t) = self.table {
288            t.current_cell.push_str(text);
289            return;
290        }
291
292        // Code block text
293        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        // Link text — style as HONEY
302        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        // List item start — prepend bullet/number
311        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        // Table cell inline code
335        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        // In standard markdown, a single newline is treated as a space (not a line break).
348        // The Paragraph widget's Wrap handles visual line breaking at the viewport width.
349        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    /// Flush accumulated spans into a Line.
366    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
514/// Fit cell content to a fixed width, with alignment support.
515fn 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}