mod emitter;
mod inline;
mod stream;
mod syntect;
mod table;
mod width;
pub use stream::MarkdownStreamWriter;
use emitter::Emitter;
use pulldown_cmark::{Options, Parser};
#[derive(Debug, Clone, Copy)]
pub struct RenderOpts {
pub color: bool,
pub cols: usize,
}
pub fn render_markdown(src: &str, opts: RenderOpts) -> String {
if !opts.color {
return src.to_string();
}
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_TABLES);
let parser = Parser::new_ext(src, options);
let mut em = Emitter::new(opts.cols);
for ev in parser {
em.handle(ev);
}
em.finish()
}
#[cfg(test)]
mod tests {
use super::{render_markdown, RenderOpts};
const BOLD: &str = "\x1b[1m";
const ITALIC: &str = "\x1b[3m";
const UNDER: &str = "\x1b[4m";
const STRIKE: &str = "\x1b[9m";
const RESET: &str = "\x1b[0m";
const ORANGE: &str = "\x1b[38;2;220;60;20m";
const FADE: &str = "\x1b[38;2;90;90;90m";
fn r(src: &str) -> String {
render_markdown(
src,
RenderOpts {
color: true,
cols: 80,
},
)
}
fn rw(src: &str, cols: usize) -> String {
render_markdown(src, RenderOpts { color: true, cols })
}
#[test]
fn plain_text_passes_through_unstyled() {
assert_eq!(r("hello world"), "hello world");
}
#[test]
fn inline_emphasis_variants() {
assert_eq!(r("**bold**"), format!("{BOLD}bold{RESET}"));
assert_eq!(r("*it*"), format!("{ITALIC}it{RESET}"));
assert_eq!(r("~~s~~"), format!("{STRIKE}s{RESET}"));
assert_eq!(r("`c`"), format!("{FADE}c{RESET}"));
}
#[test]
fn emphasis_adjacent_to_text_has_no_spurious_space() {
assert_eq!(r("un**bold**ed"), format!("un{BOLD}bold{RESET}ed"));
}
#[test]
fn heading_is_bold_orange() {
assert_eq!(r("# Title"), format!("{BOLD}{ORANGE}Title{RESET}"));
}
#[test]
fn paragraphs_are_separated_by_a_blank_line() {
assert_eq!(r("a\n\nb"), "a\n\nb");
}
#[test]
fn greedy_word_wrap_breaks_at_spaces() {
assert_eq!(rw("alpha beta gamma", 11), "alpha beta\ngamma");
}
#[test]
fn wrap_counts_display_width_not_chars() {
assert_eq!(rw("日本語 語", 8), "日本語\n語");
}
#[test]
fn bullet_list_tight() {
assert_eq!(r("- one\n- two"), "• one\n• two");
}
#[test]
fn ordered_list_numbers() {
assert_eq!(r("1. a\n2. b"), "1. a\n2. b");
}
#[test]
fn task_list_markers() {
assert_eq!(r("- [x] done\n- [ ] todo"), "• ✓ done\n• ☐ todo");
}
#[test]
fn nested_list_indents_under_parent() {
assert_eq!(r("- a\n - b"), "• a\n • b");
}
#[test]
fn blockquote_has_a_dim_bar() {
assert_eq!(r("> quote"), format!("{FADE}│ {RESET}quote"));
}
#[test]
fn fenced_code_is_dim_and_inset_not_reflowed() {
assert_eq!(
r("```\nlet x = 1;\n```"),
format!(" {FADE}let x = 1;{RESET}")
);
}
#[cfg(feature = "markdown-syntect")]
#[test]
fn syntect_highlights_a_rust_code_block() {
let out = r("```rust\nfn main() {}\n```");
assert!(
strip(&out).contains("fn main"),
"content preserved: {out:?}"
);
assert!(out.contains("\x1b[38;2;"), "has 24-bit color: {out:?}");
assert_ne!(
out,
format!(" {FADE}fn main() {{}}{RESET}"),
"not the plain dim stub"
);
}
#[test]
fn thematic_break_is_a_full_width_rule() {
assert_eq!(r("---"), format!("{FADE}{}{RESET}", "─".repeat(80)));
}
#[test]
fn link_text_is_underlined_with_dim_url() {
assert_eq!(
r("[text](https://x.io)"),
format!("{UNDER}text{RESET} {FADE}(https://x.io){RESET}")
);
}
#[test]
fn color_off_is_byte_for_byte_passthrough() {
for s in [
"**x** and `y`",
"# h\n\n- a\n- b",
"| a | b |\n| - | - |\n| 1 | 2 |",
"> quote\n\n```\ncode\n```",
"",
] {
assert_eq!(
render_markdown(
s,
RenderOpts {
color: false,
cols: 80
}
),
s,
"color-off must return source verbatim"
);
}
}
#[test]
fn empty_input_renders_empty() {
assert_eq!(r(""), "");
}
fn strip(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn table_box_drawing_skeleton() {
let t = "| a | b |\n|---|---|\n| 1 | 2 |";
assert_eq!(
strip(&r(t)),
"┌───┬───┐\n│ a │ b │\n├───┼───┤\n│ 1 │ 2 │\n└───┴───┘"
);
}
#[test]
fn table_header_is_bold_borders_are_dim() {
let out = r("| a |\n|---|\n| 1 |");
assert!(
out.contains(&format!("{BOLD}a")),
"header cell must be bold"
);
assert!(out.contains(FADE), "borders must be dim");
assert!(strip(&out).contains("│ 1 │"));
}
#[test]
fn table_right_alignment_pads_on_the_left() {
let t = "| ab |\n|---:|\n| x |";
assert!(
strip(&r(t)).contains("│ x │"),
"right-aligned cell pads left"
);
}
#[test]
fn table_overflow_truncates_with_ellipsis() {
let t = "| name |\n|------|\n| abcdefgh |";
assert!(
strip(&rw(t, 10)).contains("abcde…"),
"overflowing cell truncates with an ellipsis"
);
}
#[test]
fn table_column_width_counts_cjk_display_width() {
let t = "| 日本語 |\n|--------|\n| x |";
assert!(
strip(&r(t)).contains("┌────────┐"),
"CJK header sized by display width, not char count"
);
}
#[test]
fn table_inside_blockquote_is_prefixed_with_the_bar() {
let out = r("> | a |\n> |---|\n> | 1 |");
for line in out.lines() {
let plain = strip(line);
assert!(
plain.starts_with("│ ┌")
|| plain.starts_with("│ │")
|| plain.starts_with("│ ├")
|| plain.starts_with("│ └"),
"quoted table line must open with the bar then box: {plain:?}"
);
}
}
}