1use 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
21fn options() -> Options {
24 Options::ENABLE_STRIKETHROUGH
25 | Options::ENABLE_TABLES
26 | Options::ENABLE_TASKLISTS
27 | Options::ENABLE_GFM
28}
29
30pub 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
44pub(crate) fn has_table(text: &str) -> bool {
47 Parser::new_ext(text, options()).any(|event| matches!(event, Event::Start(Tag::Table(_))))
48}
49
50const SENTINEL: u32 = 0xE000;
53
54const MOST_MARKS: usize = 0x1900;
56
57pub 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
129struct List {
131 next: Option<u64>,
132}
133
134#[derive(Default)]
135struct Writer {
136 lines: Vec<Line<'static>>,
137 line: Vec<Span<'static>>,
139 styles: Vec<Style>,
141 lists: Vec<List>,
142 quotes: usize,
144 code: Option<(String, String)>,
147 item_marker: Option<String>,
149 links: Vec<String>,
151 table: Option<Table>,
152 gap: bool,
154 width: Option<usize>,
156 theme: Theme,
157 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 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 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 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 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 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 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 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 _ => {}
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 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 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 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 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 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 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
489fn 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;