1use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
14
15use crate::cells::cell_len;
16use crate::console::{Console, ConsoleOptions, Justify};
17use crate::protocol::Renderable;
18use crate::r#box::SIMPLE;
19use crate::segment::Segment;
20use crate::style::Style;
21use crate::syntax::Syntax;
22use crate::table::Table;
23use crate::text::Text;
24
25const CODE_STYLE: &str = "bold cyan on black"; const BULLET: &str = " \u{2022} "; const QUOTE_PREFIX: &str = "\u{258c} "; const LINK_STYLE: &str = "underline blue"; const TABLE_BORDER_STYLE: &str = "cyan"; const TABLE_HEADER_STYLE: &str = "not bold cyan"; enum Block {
34 Text(Text),
36 List {
38 ordered: bool,
39 start: u64,
40 items: Vec<Text>,
41 },
42 Quote(Vec<Text>),
44 Code { language: String, code: String },
46 Rule,
48 Table {
51 alignments: Vec<Justify>,
52 headers: Vec<String>,
53 rows: Vec<Vec<String>>,
54 },
55}
56
57#[derive(Default)]
59struct TableAccum {
60 alignments: Vec<Justify>,
61 headers: Vec<String>,
62 rows: Vec<Vec<String>>,
63 in_head: bool,
64 in_cell: bool,
65 cur_row: Vec<String>,
66 cur_cell: String,
67}
68
69fn alignment_justify(alignment: Alignment) -> Justify {
70 match alignment {
71 Alignment::Right => Justify::Right,
72 Alignment::Center => Justify::Center,
73 Alignment::Left | Alignment::None => Justify::Left,
75 }
76}
77
78pub struct Markdown {
80 blocks: Vec<Block>,
81}
82
83impl Markdown {
84 pub fn new(source: &str) -> Self {
86 Markdown {
87 blocks: parse(source),
88 }
89 }
90}
91
92fn heading_level(level: HeadingLevel) -> usize {
93 match level {
94 HeadingLevel::H1 => 1,
95 HeadingLevel::H2 => 2,
96 HeadingLevel::H3 => 3,
97 HeadingLevel::H4 => 4,
98 HeadingLevel::H5 => 5,
99 HeadingLevel::H6 => 6,
100 }
101}
102
103fn heading_format(level: usize) -> (Style, Justify) {
106 let (spec, justify) = match level {
107 1 => ("bold underline", Justify::Center),
108 2 => ("underline magenta", Justify::Left),
109 3 => ("bold magenta", Justify::Left),
110 4 => ("italic magenta", Justify::Left),
111 5 => ("italic", Justify::Left),
112 _ => ("dim", Justify::Left),
113 };
114 (Style::parse(spec).unwrap_or_default(), justify)
115}
116
117fn inline_style(strong: usize, emphasis: usize) -> Option<Style> {
118 if strong == 0 && emphasis == 0 {
119 return None;
120 }
121 let mut style = Style::new();
122 if strong > 0 {
123 style = style.combine(&Style::parse("bold").expect("valid style"));
124 }
125 if emphasis > 0 {
126 style = style.combine(&Style::parse("italic").expect("valid style"));
127 }
128 Some(style)
129}
130
131fn parse(source: &str) -> Vec<Block> {
132 let mut blocks: Vec<Block> = Vec::new();
133 let mut current: Option<Text> = None;
134 let mut heading_style: Option<Style> = None;
135 let mut justify = Justify::Left;
136 let mut strong = 0usize;
137 let mut emphasis = 0usize;
138 let mut list: Option<(bool, u64, Vec<Text>)> = None;
140 let mut quote: Option<Vec<Text>> = None;
142 let mut code: Option<(String, String)> = None;
144 let mut link: Option<String> = None;
146 let mut table: Option<TableAccum> = None;
148
149 for event in Parser::new_ext(source, Options::ENABLE_TABLES) {
150 match event {
151 Event::Rule => blocks.push(Block::Rule),
152 Event::Start(Tag::Link { dest_url, .. }) => link = Some(dest_url.to_string()),
153 Event::End(TagEnd::Link) => link = None,
154 Event::Start(Tag::CodeBlock(kind)) => {
155 let language = match kind {
156 CodeBlockKind::Fenced(info) => {
157 info.split_whitespace().next().unwrap_or("").to_string()
159 }
160 CodeBlockKind::Indented => String::new(),
161 };
162 code = Some((language, String::new()));
163 }
164 Event::End(TagEnd::CodeBlock) => {
165 if let Some((language, mut source)) = code.take() {
166 if source.ends_with('\n') {
168 source.pop();
169 }
170 blocks.push(Block::Code {
171 language,
172 code: source,
173 });
174 }
175 }
176 Event::Start(Tag::Table(aligns)) => {
177 table = Some(TableAccum {
178 alignments: aligns.into_iter().map(alignment_justify).collect(),
179 ..TableAccum::default()
180 });
181 }
182 Event::End(TagEnd::Table) => {
183 if let Some(acc) = table.take() {
184 blocks.push(Block::Table {
185 alignments: acc.alignments,
186 headers: acc.headers,
187 rows: acc.rows,
188 });
189 }
190 }
191 Event::Start(Tag::TableHead) => {
192 if let Some(acc) = table.as_mut() {
193 acc.in_head = true;
194 acc.cur_row = Vec::new();
195 }
196 }
197 Event::End(TagEnd::TableHead) => {
198 if let Some(acc) = table.as_mut() {
199 acc.headers = std::mem::take(&mut acc.cur_row);
200 acc.in_head = false;
201 }
202 }
203 Event::Start(Tag::TableRow) => {
204 if let Some(acc) = table.as_mut() {
205 acc.cur_row = Vec::new();
206 }
207 }
208 Event::End(TagEnd::TableRow) => {
209 if let Some(acc) = table.as_mut() {
210 let row = std::mem::take(&mut acc.cur_row);
211 acc.rows.push(row);
212 }
213 }
214 Event::Start(Tag::TableCell) => {
215 if let Some(acc) = table.as_mut() {
216 acc.in_cell = true;
217 acc.cur_cell = String::new();
218 }
219 }
220 Event::End(TagEnd::TableCell) => {
221 if let Some(acc) = table.as_mut() {
222 let cell = std::mem::take(&mut acc.cur_cell);
223 acc.cur_row.push(cell);
224 acc.in_cell = false;
225 }
226 }
227 Event::Start(Tag::BlockQuote(_)) => quote = Some(Vec::new()),
228 Event::End(TagEnd::BlockQuote(_)) => {
229 if let Some(paragraphs) = quote.take() {
230 blocks.push(Block::Quote(paragraphs));
231 }
232 }
233 Event::Start(Tag::List(first)) => {
234 list = Some((first.is_some(), first.unwrap_or(1), Vec::new()))
235 }
236 Event::End(TagEnd::List(_)) => {
237 if let Some((ordered, start, items)) = list.take() {
238 blocks.push(Block::List {
239 ordered,
240 start,
241 items,
242 });
243 }
244 }
245 Event::Start(Tag::Item) => {
246 current = Some(Text::new(""));
247 heading_style = None;
248 justify = Justify::Left;
249 }
250 Event::End(TagEnd::Item) => {
251 if let (Some(mut text), Some((_, _, items))) = (current.take(), list.as_mut()) {
252 text.set_justify(Justify::Left);
253 items.push(text);
254 }
255 }
256 Event::Start(Tag::Paragraph) if current.is_none() => {
258 current = Some(Text::new(""));
259 heading_style = None;
260 justify = Justify::Left;
261 }
262 Event::Start(Tag::Heading { level, .. }) => {
263 let (style, heading_justify) = heading_format(heading_level(level));
264 current = Some(Text::new(""));
265 heading_style = Some(style);
266 justify = heading_justify;
267 }
268 Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Heading(_)) if list.is_none() => {
270 if let Some(mut text) = current.take() {
271 if let Some(paragraphs) = quote.as_mut() {
272 text.set_base_style(Style::parse("magenta").expect("valid style"));
274 text.set_justify(Justify::Left);
275 paragraphs.push(text);
276 } else {
277 if let Some(style) = &heading_style {
278 let end = text.plain().len();
279 text.stylize(style.clone(), 0, end);
280 }
281 text.set_justify(justify);
282 blocks.push(Block::Text(text));
283 }
284 }
285 strong = 0;
286 emphasis = 0;
287 }
288 Event::Start(Tag::Strong) => strong += 1,
289 Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1),
290 Event::Start(Tag::Emphasis) => emphasis += 1,
291 Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1),
292 Event::Text(text) => {
293 if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
294 acc.cur_cell.push_str(&text);
297 } else if let Some((_, source)) = code.as_mut() {
298 source.push_str(&text);
299 } else if let Some(block) = current.as_mut() {
300 let style = match &link {
303 Some(url) => Style::parse(LINK_STYLE)
304 .ok()
305 .map(|s| s.with_link(url.clone())),
306 None => inline_style(strong, emphasis),
307 };
308 block.append(&text, style.map(Into::into));
309 }
310 }
311 Event::Code(text) => {
312 if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
313 acc.cur_cell.push_str(&text);
314 } else if let Some(block) = current.as_mut() {
315 block.append(&text, Style::parse(CODE_STYLE).ok().map(Into::into));
316 }
317 }
318 Event::SoftBreak => {
319 if let Some(block) = current.as_mut() {
320 block.append(" ", None);
321 }
322 }
323 Event::HardBreak => {
324 if let Some(block) = current.as_mut() {
325 block.append("\n", None);
326 }
327 }
328 _ => {}
329 }
330 }
331 blocks
332}
333
334impl Renderable for Markdown {
335 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
336 let width = options.max_width;
337 let base = console.base_style();
338 let mut lines: Vec<Vec<Segment>> = Vec::new();
339
340 for (index, block) in self.blocks.iter().enumerate() {
341 if index > 0
344 || matches!(
345 block,
346 Block::List { .. } | Block::Quote(_) | Block::Table { .. }
347 )
348 {
349 lines.push(Vec::new());
350 }
351 match block {
352 Block::Text(text) => {
353 lines.extend(text.render_lines(console.theme(), base, Some(width)))
354 }
355 Block::List {
356 ordered,
357 start,
358 items,
359 } => {
360 for (number, item) in (*start..).zip(items.iter()) {
361 let (prefix, prefix_style) = if *ordered {
362 (
363 format!(" {number} "),
364 Style::parse("cyan").expect("valid style"),
365 )
366 } else {
367 (
368 BULLET.to_string(),
369 Style::parse("bold").expect("valid style"),
370 )
371 };
372 let prefix_width = cell_len(&prefix);
373 let item_lines = item.render_lines(
374 console.theme(),
375 base,
376 Some(width.saturating_sub(prefix_width)),
377 );
378 for (line_index, line) in item_lines.into_iter().enumerate() {
379 let mut row = Vec::new();
380 if line_index == 0 {
381 row.push(Segment::new(prefix.clone(), Some(prefix_style.clone())));
382 } else {
383 row.push(Segment::new(" ".repeat(prefix_width), None));
384 }
385 row.extend(line);
386 lines.push(row);
387 }
388 }
389 }
390 Block::Quote(paragraphs) => {
391 let prefix_style = Style::parse("magenta").expect("valid style");
392 let content_width = width.saturating_sub(4);
394 for paragraph in paragraphs {
395 let quote_lines =
396 paragraph.render_lines(console.theme(), base, Some(content_width));
397 for line in quote_lines {
398 let mut row = vec![Segment::new(
399 QUOTE_PREFIX.to_string(),
400 Some(prefix_style.clone()),
401 )];
402 row.extend(line);
403 lines.push(row);
404 }
405 }
406 }
407 Block::Code { language, code } => {
408 let syntax = Syntax::new(code.as_str(), language.as_str());
412 let segments = syntax.rich_render(console, options);
413 lines.extend(Segment::split_lines(&segments));
414 }
415 Block::Rule => {
416 let style = Style::parse("dim").expect("valid style");
417 lines.push(vec![Segment::new("-".repeat(width), Some(style))]);
418 }
419 Block::Table {
420 alignments,
421 headers,
422 rows,
423 } => {
424 let mut table = Table::new()
429 .box_set(SIMPLE)
430 .pad_edge(false)
431 .collapse_padding(true)
432 .style(Style::parse(TABLE_BORDER_STYLE).expect("valid style"));
433 let header_style = Style::parse(TABLE_HEADER_STYLE).expect("valid style");
434 for (col, header) in headers.iter().enumerate() {
435 let justify = alignments.get(col).copied().unwrap_or(Justify::Left);
436 table.add_column_justify(header.as_str(), justify);
437 table.column_header_style(header_style.clone());
438 }
439 for row in rows {
440 let refs: Vec<&str> = row.iter().map(String::as_str).collect();
441 table.add_row(&refs);
442 }
443 let segments = table.rich_render(console, options);
444 lines.extend(Segment::split_lines(&segments));
445 }
446 }
447 }
448
449 if matches!(self.blocks.last(), Some(Block::Rule)) {
454 lines.push(Vec::new());
455 }
456
457 let mut segments = Vec::new();
458 let last = lines.len().saturating_sub(1);
459 for (index, line) in lines.into_iter().enumerate() {
460 segments.extend(line);
461 if index != last {
462 segments.push(Segment::line());
463 }
464 }
465 segments
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::color::ColorSystem;
473
474 fn render(source: &str) -> String {
475 let console = Console::builder()
476 .force_terminal(true)
477 .color_system(Some(ColorSystem::Truecolor))
478 .width(20)
479 .build();
480 console.render_to_string(&Markdown::new(source))
481 }
482
483 #[test]
484 fn paragraph_inline_styles() {
485 assert_eq!(
486 render("a `x` b"),
487 "a \x1b[1;36;40mx\x1b[0m b "
488 );
489 }
490
491 #[test]
492 fn link_renders_osc8_hyperlink() {
493 let out = render("See [the site](https://example.com) now.");
497 assert!(
498 out.contains(
499 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mthe site\x1b[0m\x1b]8;;\x1b\\"
500 ),
501 "got {out:?}"
502 );
503 assert!(!out.contains("id="), "we omit the random link id");
504 }
505
506 #[test]
507 fn fenced_code_block_is_highlighted() {
508 let console = Console::builder()
511 .force_terminal(true)
512 .color_system(Some(ColorSystem::Truecolor))
513 .width(24)
514 .no_color(false)
515 .build();
516 let out = console.render_to_string(&Markdown::new("```rust\nfn main() {}\n```"));
517 assert!(out.contains("fn"), "got {out:?}");
518 assert!(out.contains("main"));
519 assert!(out.contains('\x1b'), "code block should be colored");
520 }
521
522 #[test]
523 fn headings() {
524 assert_eq!(render("# Head"), " \x1b[1;4mHead\x1b[0m ");
525 assert_eq!(render("## Sub"), "\x1b[4;35mSub\x1b[0m ");
526 }
527
528 #[test]
529 fn two_paragraphs_separated_by_blank_line() {
530 assert_eq!(
531 render("First para.\n\nSecond para."),
532 "First para. \n\nSecond para. "
533 );
534 }
535
536 #[test]
537 fn bullet_list() {
538 assert_eq!(
539 render("- one\n- two"),
540 "\n\x1b[1m \u{2022} \x1b[0mone \n\x1b[1m \u{2022} \x1b[0mtwo "
541 );
542 }
543
544 #[test]
545 fn ordered_list() {
546 assert_eq!(
547 render("1. first\n2. second"),
548 "\n\x1b[36m 1 \x1b[0mfirst \n\x1b[36m 2 \x1b[0msecond "
549 );
550 }
551
552 #[test]
553 fn block_quote() {
554 assert_eq!(
555 render("> quoted text"),
556 "\n\x1b[35m\u{258c} \x1b[0m\x1b[35mquoted text\x1b[0m\x1b[35m \x1b[0m"
557 );
558 }
559
560 #[test]
561 fn gfm_table() {
562 let console = Console::builder()
565 .force_terminal(true)
566 .color_system(Some(ColorSystem::Truecolor))
567 .width(40)
568 .no_color(false)
569 .build();
570 let md = "| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n";
571 let out = console.render_to_string(&Markdown::new(md));
572 assert!(out.contains("Name"), "header present: {out:?}");
573 assert!(out.contains("Alice"), "body cell present");
574 assert!(out.contains('\u{2500}'), "SIMPLE box head rule present");
575 assert!(out.contains(" 30"), "right-justified 30");
577 assert!(out.contains(" 7"), "right-justified 7");
578 }
579
580 #[test]
581 fn thematic_break() {
582 assert_eq!(
583 render("a\n\n---\n\nb"),
584 "a \n\n\x1b[2m--------------------\x1b[0m\n\nb "
585 );
586 }
587
588 #[test]
589 fn thematic_break_at_end_adds_trailing_blank() {
590 assert_eq!(
594 render("a\n\n---"),
595 "a \n\n\x1b[2m--------------------\x1b[0m\n"
596 );
597 }
598}