Skip to main content

rig_tui/
markdown.rs

1//! Markdown in the model's answers drawn as styled lines: headings,
2//! emphasis, inline code, fenced code, lists, quotes and alerts, links,
3//! rules and tables. Parsing is pulldown-cmark's, with strikethrough,
4//! tables, task lists and GitHub's alerts on. Code sits on the theme's code
5//! tint, with a fenced block's language over it, and an alert (`> [!NOTE]`)
6//! on the tint of what it is, under its name in bold.
7
8use pulldown_cmark::{
9    BlockQuoteKind, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd,
10};
11use ratatui::style::{Color, Style, Stylize};
12use ratatui::text::{Line, Span};
13
14use crate::Theme;
15use crate::highlight::{Language, highlight};
16
17use table::Table;
18
19mod table;
20
21/// The extensions parsed: strikethrough, tables, task lists and GitHub's
22/// alerts.
23fn options() -> Options {
24    Options::ENABLE_STRIKETHROUGH
25        | Options::ENABLE_TABLES
26        | Options::ENABLE_TASKLISTS
27        | Options::ENABLE_GFM
28}
29
30/// `text` as markdown in `theme`'s tints, in lines not yet wrapped, its
31/// tables fit to `width` (at their widest cells with `None`).
32pub fn render(text: &str, theme: &Theme, width: Option<usize>) -> Vec<Line<'static>> {
33    let mut writer = Writer {
34        width,
35        theme: *theme,
36        ..Writer::default()
37    };
38    for event in Parser::new_ext(text, options()) {
39        writer.event(event);
40    }
41    writer.finish()
42}
43
44/// Whether `text` as markdown has a table, so its lines depend on the
45/// width they are rendered at.
46pub(crate) fn has_table(text: &str) -> bool {
47    Parser::new_ext(text, options()).any(|event| matches!(event, Event::Start(Tag::Table(_))))
48}
49
50/// The first of the private-use characters [`render_marked`] stands
51/// marks in with.
52const SENTINEL: u32 = 0xE000;
53
54/// The most marks one text takes: the private-use area's size.
55const MOST_MARKS: usize = 0x1900;
56
57/// `text` as markdown at `width`, as [`render`] draws it, with each of `marks` drawn
58/// before its byte: past the end at the end, inside a character before
59/// it. Each mark stands in the text as a private-use character while it
60/// is rendered, so markdown on both sides of it is kept.
61pub fn render_marked(
62    text: &str,
63    marks: &[(usize, Span<'static>)],
64    theme: &Theme,
65    width: Option<usize>,
66) -> Vec<Line<'static>> {
67    if marks.is_empty() {
68        return render(text, theme, width);
69    }
70    let mut placed: Vec<(usize, usize)> = marks
71        .iter()
72        .take(MOST_MARKS)
73        .enumerate()
74        .map(|(index, (at, _))| {
75            let mut at = (*at).min(text.len());
76            while !text.is_char_boundary(at) {
77                at = at.saturating_sub(1);
78            }
79            (at, index)
80        })
81        .collect();
82    placed.sort_by_key(|(at, _)| *at);
83    let mut marked = String::with_capacity(text.len() + 3 * placed.len());
84    let mut from = 0;
85    for (at, index) in &placed {
86        marked.push_str(text.get(from..*at).unwrap_or_default());
87        let code = SENTINEL.saturating_add(u32::try_from(*index).unwrap_or(u32::MAX));
88        marked.extend(char::from_u32(code));
89        from = *at;
90    }
91    marked.push_str(text.get(from..).unwrap_or_default());
92    let mark_of = |ch: char| {
93        let index = u32::from(ch).checked_sub(SENTINEL)?;
94        marks
95            .get(usize::try_from(index).ok()?)
96            .map(|(_, span)| span)
97    };
98    let swap = |span: Span<'static>| -> Vec<Span<'static>> {
99        if !span.content.chars().any(|ch| mark_of(ch).is_some()) {
100            return vec![span];
101        }
102        let mut spans = Vec::new();
103        let mut run = String::new();
104        for ch in span.content.chars() {
105            match mark_of(ch) {
106                Some(mark) => {
107                    if !run.is_empty() {
108                        spans.push(Span::styled(std::mem::take(&mut run), span.style));
109                    }
110                    spans.push(mark.clone());
111                }
112                None => run.push(ch),
113            }
114        }
115        if !run.is_empty() {
116            spans.push(Span::styled(run, span.style));
117        }
118        spans
119    };
120    render(&marked, theme, width)
121        .into_iter()
122        .map(|line| {
123            let spans = line.spans.into_iter().flat_map(swap).collect::<Vec<_>>();
124            Line { spans, ..line }
125        })
126        .collect()
127}
128
129/// A list being written: the next item's number, or `None` for bullets.
130struct List {
131    next: Option<u64>,
132}
133
134#[derive(Default)]
135struct Writer {
136    lines: Vec<Line<'static>>,
137    /// The line being written.
138    line: Vec<Span<'static>>,
139    /// Inline styles open, innermost last.
140    styles: Vec<Style>,
141    lists: Vec<List>,
142    /// How deep in block quotes.
143    quotes: usize,
144    /// Inside a fenced or indented code block: its fence's language, and
145    /// its text so far, drawn whole at its end.
146    code: Option<(String, String)>,
147    /// The prefix of the current item's first line, until it is written.
148    item_marker: Option<String>,
149    /// Link targets of the open links, written after their text.
150    links: Vec<String>,
151    table: Option<Table>,
152    /// A blank line goes before the next block.
153    gap: bool,
154    /// What tables are fit to.
155    width: Option<usize>,
156    theme: Theme,
157    /// The open alerts: the first of their lines and their tint, `None`
158    /// for a plain quote.
159    alerts: Vec<Option<(usize, Color)>>,
160}
161
162impl Writer {
163    fn style(&self) -> Style {
164        self.styles
165            .iter()
166            .fold(Style::new(), |style, inner| style.patch(*inner))
167    }
168
169    /// The indent of the current line: the quote depth and the list depth.
170    fn prefix(&mut self) -> Vec<Span<'static>> {
171        let mut prefix = vec![Span::from("  ".repeat(self.quotes))];
172        let depth = self.lists.len();
173        if depth > 0 {
174            match self.item_marker.take() {
175                Some(marker) => {
176                    prefix.push(Span::from("  ".repeat(depth - 1)));
177                    prefix.push(Span::from(marker).cyan());
178                }
179                None => prefix.push(Span::from("  ".repeat(depth))),
180            }
181        }
182        prefix
183    }
184
185    /// Starts a block: a blank line after the previous one.
186    fn block(&mut self) {
187        self.flush();
188        if self.gap && !self.lines.is_empty() && self.lists.is_empty() {
189            self.lines.push(Line::default());
190        }
191        self.gap = false;
192    }
193
194    /// Ends the current line, if it has text.
195    fn flush(&mut self) {
196        if self.line.is_empty() {
197            return;
198        }
199        let mut spans = self.prefix();
200        spans.append(&mut self.line);
201        self.lines.push(Line::from(spans));
202    }
203
204    /// Writes `span` on the current line, or in the current table cell.
205    fn push(&mut self, span: Span<'static>) {
206        match &mut self.table {
207            Some(table) => {
208                let cell = table.rows.last_mut().and_then(|row| row.last_mut());
209                if let Some(line) = cell.and_then(|cell| cell.last_mut()) {
210                    line.push(span);
211                }
212            }
213            None => self.line.push(span),
214        }
215    }
216
217    fn text(&mut self, text: &str) {
218        if let Some((_, code)) = &mut self.code {
219            code.push_str(text);
220            return;
221        }
222        self.push(Span::styled(text.to_owned(), self.style()));
223    }
224
225    /// Writes a code block's `code`, in the colors of its fence's
226    /// `language` when it has a grammar, each line kept, empty ones too.
227    /// A code block on the code tint, under its language when it names one.
228    fn code_block(&mut self, language: &str, code: &str) {
229        let code = code.strip_suffix('\n').unwrap_or(code);
230        let plain = || {
231            let lines = code.split('\n');
232            lines
233                .map(|line| vec![Span::from(line.replace('\t', "    "))])
234                .collect()
235        };
236        let lines = match language {
237            "" => plain(),
238            language => highlight(code, Language::Named(language)).unwrap_or_else(plain),
239        };
240        let tint = self.theme.code;
241        if !language.is_empty() {
242            let mut spans = self.prefix();
243            spans.push(Span::from(format!("  {language}")).dim().italic());
244            self.lines.push(Line::from(spans).bg(tint));
245        }
246        for line in lines {
247            let mut spans = self.prefix();
248            spans.push(Span::from("  "));
249            spans.extend(line);
250            self.lines.push(Line::from(spans).bg(tint));
251        }
252    }
253
254    /// Inline code: on the code tint, or in its backticks with no colors.
255    fn inline_code(&mut self, code: &str) {
256        let span = match self.theme.plain {
257            true => Span::from(format!("`{code}`")),
258            false => Span::styled(code.to_owned(), Style::new().bg(self.theme.code)),
259        };
260        self.push(span.patch_style(self.style()));
261    }
262
263    fn event(&mut self, event: Event<'_>) {
264        match event {
265            Event::Start(tag) => self.start(tag),
266            Event::End(tag) => self.end(tag),
267            Event::Text(text) => self.text(&text),
268            Event::Code(code) => self.inline_code(&code),
269            Event::Html(html) | Event::InlineHtml(html) if is_break(&html) => self.hard_break(),
270            Event::Html(html) | Event::InlineHtml(html) => {
271                let style = self.style().dim();
272                for (index, line) in html.trim_end_matches('\n').split('\n').enumerate() {
273                    if index > 0 {
274                        self.flush();
275                    }
276                    self.line.push(Span::styled(line.to_owned(), style));
277                }
278            }
279            Event::SoftBreak => self.line.push(Span::from(" ")),
280            Event::HardBreak => self.hard_break(),
281            Event::Rule => {
282                self.block();
283                // A hairline: blank cells underlined.
284                self.lines
285                    .push(Line::from(" ".repeat(40)).dark_gray().underlined());
286                self.gap = true;
287            }
288            Event::TaskListMarker(done) => {
289                let mark = match done {
290                    true => Span::from("✓ ").green().bold(),
291                    false => Span::from("○ ").dim(),
292                };
293                self.line.push(mark);
294            }
295            // Math and footnotes, which `render` does not turn on.
296            _ => {}
297        }
298    }
299
300    fn start(&mut self, tag: Tag<'_>) {
301        match tag {
302            Tag::Paragraph => self.block(),
303            Tag::Heading { level, .. } => {
304                self.block();
305                let style = match level {
306                    HeadingLevel::H1 => Style::new().bold().underlined().cyan(),
307                    HeadingLevel::H2 => Style::new().bold().cyan(),
308                    _ => Style::new().bold(),
309                };
310                self.styles.push(style);
311            }
312            Tag::BlockQuote(kind) => {
313                self.block();
314                let alert = kind.map(|kind| self.alert(kind));
315                self.quotes += 1;
316                // A plain quote is set apart in gray italics; an alert by its tint.
317                let style = match alert {
318                    Some(_) => Style::new(),
319                    None => Style::new().italic().dark_gray(),
320                };
321                self.styles.push(style);
322                self.alerts.push(alert);
323            }
324            // The fence's info string (the language) is not content: it
325            // colors the block's code.
326            Tag::CodeBlock(kind) => {
327                self.block();
328                let language = match kind {
329                    CodeBlockKind::Fenced(info) => {
330                        info.split_whitespace().next().map(str::to_owned)
331                    }
332                    CodeBlockKind::Indented => None,
333                };
334                self.code = Some((language.unwrap_or_default(), String::new()));
335            }
336            Tag::List(start) => {
337                if self.lists.is_empty() {
338                    self.block();
339                } else {
340                    self.flush();
341                }
342                self.lists.push(List { next: start });
343            }
344            Tag::Item => {
345                self.flush();
346                let marker = match self.lists.last_mut() {
347                    Some(List { next: Some(number) }) => {
348                        let marker = format!("{number}. ");
349                        *number += 1;
350                        marker
351                    }
352                    _ => "• ".to_owned(),
353                };
354                self.item_marker = Some(marker);
355            }
356            Tag::Emphasis => self.styles.push(Style::new().italic()),
357            Tag::Strong => self.styles.push(Style::new().bold()),
358            Tag::Strikethrough => self.styles.push(Style::new().crossed_out()),
359            // An image is drawn as a link to it.
360            Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. } => {
361                self.styles.push(Style::new().underlined().blue());
362                self.links.push(dest_url.to_string());
363            }
364            Tag::Table(_) => {
365                self.block();
366                self.table = Some(Table::default());
367            }
368            Tag::TableHead | Tag::TableRow => {
369                if let Some(table) = &mut self.table {
370                    table.rows.push(Vec::new());
371                }
372            }
373            Tag::TableCell => {
374                if let Some(row) = self.table.as_mut().and_then(|table| table.rows.last_mut()) {
375                    row.push(vec![Vec::new()]);
376                }
377            }
378            _ => {}
379        }
380    }
381
382    fn end(&mut self, tag: TagEnd) {
383        match tag {
384            TagEnd::Paragraph | TagEnd::HtmlBlock => {
385                self.flush();
386                self.gap = true;
387            }
388            TagEnd::Heading(_) => {
389                self.styles.pop();
390                self.flush();
391                self.gap = true;
392            }
393            TagEnd::BlockQuote(_) => {
394                self.flush();
395                self.quotes = self.quotes.saturating_sub(1);
396                self.styles.pop();
397                if let Some(Some((first, tint))) = self.alerts.pop() {
398                    for line in self.lines.iter_mut().skip(first) {
399                        line.style.bg = line.style.bg.or(Some(tint));
400                    }
401                }
402                self.gap = true;
403            }
404            TagEnd::CodeBlock => {
405                if let Some((language, code)) = self.code.take() {
406                    self.code_block(&language, &code);
407                }
408                self.gap = true;
409            }
410            TagEnd::List(_) => {
411                self.flush();
412                self.lists.pop();
413                self.gap = true;
414            }
415            TagEnd::Item => self.flush(),
416            TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
417                self.styles.pop();
418            }
419            TagEnd::Link | TagEnd::Image => {
420                self.styles.pop();
421                if let Some(url) = self.links.pop() {
422                    let shown = self
423                        .line
424                        .last()
425                        .is_some_and(|span| span.content.as_ref() == url);
426                    if !shown && !url.is_empty() {
427                        self.line.push(Span::from(format!(" ({url})")).dark_gray());
428                    }
429                }
430            }
431            TagEnd::Table => {
432                if let Some(table) = self.table.take() {
433                    self.table_lines(table);
434                }
435                self.gap = true;
436            }
437            TagEnd::TableHead => {
438                if let Some(table) = &mut self.table {
439                    table.header_rows = table.rows.len();
440                }
441            }
442            _ => {}
443        }
444    }
445
446    /// Writes a table fit to the width, in the block it is in.
447    fn table_lines(&mut self, table: Table) {
448        let prefixes = (self.prefix(), self.prefix());
449        let lines = table::lines(table, prefixes, self.width);
450        self.lines.extend(lines);
451    }
452
453    /// Starts an alert of `kind`: its name in bold on its first line; the
454    /// first of its lines and its tint.
455    fn alert(&mut self, kind: BlockQuoteKind) -> (usize, Color) {
456        let theme = self.theme;
457        let (name, tint) = match kind {
458            BlockQuoteKind::Note => ("Note", theme.note),
459            BlockQuoteKind::Tip => ("Tip", theme.call_done),
460            BlockQuoteKind::Important => ("Important", theme.important),
461            BlockQuoteKind::Warning => ("Warning", theme.call_running),
462            BlockQuoteKind::Caution => ("Caution", theme.call_failed),
463        };
464        let first = self.lines.len();
465        let mut spans = self.prefix();
466        spans.push(Span::from(name).bold());
467        self.lines.push(Line::from(spans));
468        (first, tint)
469    }
470
471    /// Ends the line here: in a table cell, starts the cell's next line.
472    fn hard_break(&mut self) {
473        match &mut self.table {
474            Some(table) => {
475                if let Some(cell) = table.rows.last_mut().and_then(|row| row.last_mut()) {
476                    cell.push(Vec::new());
477                }
478            }
479            None => self.flush(),
480        }
481    }
482
483    fn finish(mut self) -> Vec<Line<'static>> {
484        self.flush();
485        self.lines
486    }
487}
488
489/// Whether `html` is a line break: `<br>`, `<br/>` or `<br />`, in any case.
490fn is_break(html: &str) -> bool {
491    let tag: String = html
492        .chars()
493        .filter(|c| !c.is_whitespace() && *c != '/')
494        .collect();
495    tag.eq_ignore_ascii_case("<br>")
496}
497
498#[cfg(test)]
499mod tests;