Skip to main content

markdown_to_ansi/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod highlight;
4mod render;
5mod wrap;
6
7use pulldown_cmark::{Options as CmarkOptions, Parser};
8
9pub use highlight::has_syntax;
10
11/// Options controlling rendering behavior.
12pub struct Options {
13    /// Whether to syntax-highlight fenced code blocks.
14    pub syntax_highlight: bool,
15    /// Available column width for text wrapping and code block background padding.
16    /// `None` means no wrapping; code blocks default to 80 columns.
17    pub width: Option<usize>,
18    /// Whether to draw background colors on syntax-highlighted code blocks.
19    ///
20    /// When `true` (the default), code blocks get a dark background that
21    /// extends to the full column width.  When `false`, only foreground
22    /// syntax colours are emitted — useful for screenshot tools that do not
23    /// render per-character backgrounds well.
24    pub code_bg: bool,
25}
26
27/// Render markdown to ANSI text (block-level: paragraphs, headers, code blocks, lists, tables).
28pub fn render(text: &str, opts: &Options) -> String {
29    let cmark_opts = CmarkOptions::ENABLE_TABLES;
30    let parser = Parser::new_ext(text, cmark_opts);
31    render::render_events(parser, opts, false)
32}
33
34/// Render inline markdown only (code spans, bold, links, raw URLs).
35///
36/// The input is parsed as a full `CommonMark` document but outer paragraph
37/// wrapper events are stripped so only inline content is emitted.
38pub fn render_inline(text: &str, opts: &Options) -> String {
39    let parser = Parser::new_ext(text, CmarkOptions::empty());
40    render::render_events(parser, opts, true)
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46    use ansi_term_styles::{BLUE, BOLD, RESET, UNDERLINE};
47
48    fn opts() -> Options {
49        Options {
50            syntax_highlight: true,
51            width: None,
52            code_bg: true,
53        }
54    }
55
56    #[test]
57    fn paragraph_reflow() {
58        let input = "first line\nsecond line";
59        let result = render(input, &opts());
60        assert!(
61            result.contains("first line second line"),
62            "Single newlines should become spaces, got: {result:?}"
63        );
64    }
65
66    #[test]
67    fn paragraph_break_preserved() {
68        let input = "first paragraph\n\nsecond paragraph";
69        let result = render(input, &opts());
70        assert!(
71            result.contains("first paragraph\n\nsecond paragraph"),
72            "Double newlines should preserve paragraph breaks, got: {result:?}"
73        );
74    }
75
76    #[test]
77    fn h1_bold_underline_extra_newline() {
78        let result = render("# My Header\nSome text", &opts());
79        assert!(result.contains(BOLD), "H1 should be bold");
80        assert!(result.contains(UNDERLINE), "H1 should be underlined");
81        assert!(result.contains("My Header"));
82        assert!(!result.contains("# "));
83        // H1 gets an extra blank line after it.
84        assert!(
85            result.contains(&format!("{RESET}\n\n")),
86            "H1 should have extra newline, got: {result:?}"
87        );
88    }
89
90    #[test]
91    fn h2_bold_underline() {
92        let result = render("## Subheading\nSome text", &opts());
93        assert!(result.contains(BOLD), "H2 should be bold");
94        assert!(result.contains(UNDERLINE), "H2 should be underlined");
95    }
96
97    #[test]
98    fn h3_bold_no_underline() {
99        let result = render("### Minor\nSome text", &opts());
100        assert!(result.contains(BOLD), "H3 should be bold");
101        assert!(!result.contains(UNDERLINE), "H3 should not be underlined");
102    }
103
104    #[test]
105    fn inline_code_blue() {
106        let result = render_inline("Use `foo` and `bar`", &opts());
107        assert!(result.contains(BLUE));
108        assert!(result.contains("foo"));
109        assert!(result.contains("bar"));
110        assert!(!result.contains('`'));
111    }
112
113    #[test]
114    fn inline_bold() {
115        let result = render_inline("This is **important** text", &opts());
116        assert!(result.contains(BOLD));
117        assert!(result.contains("important"));
118        assert!(!result.contains("**"));
119    }
120
121    #[test]
122    fn inline_link_osc8() {
123        let result = render_inline("See [docs](https://example.com) here", &opts());
124        assert!(
125            result.contains("\x1b]8;;https://example.com\x1b\\"),
126            "should contain OSC 8 hyperlink, got: {result:?}"
127        );
128        assert!(result.contains(UNDERLINE));
129        assert!(result.contains("docs"));
130        assert!(result.contains("\x1b]8;;\x1b\\"));
131    }
132
133    #[test]
134    fn code_block_highlighted() {
135        let input = "Some text\n\n```toml\n[package]\nname = \"test\"\n```\n\nMore text";
136        let result = render(input, &opts());
137        assert!(result.contains("\x1b["));
138        assert!(!result.contains("```"));
139        assert!(result.contains("package"));
140        assert!(result.contains("test"));
141        assert!(result.contains("Some text"));
142        assert!(result.contains("More text"));
143    }
144
145    #[test]
146    fn code_block_no_syntax_highlight_preserves_fences() {
147        let no_highlight = Options {
148            syntax_highlight: false,
149            width: None,
150            code_bg: true,
151        };
152        let input = "Before\n\n```toml\n[package]\nname = \"test\"\n```\n\nAfter";
153        let result = render(input, &no_highlight);
154        assert!(result.contains("```toml"));
155        assert!(result.contains("[package]"));
156        assert!(result.contains("name = \"test\""));
157        assert!(!result.contains("\x1b[38;2;"));
158        assert!(result.contains("Before"));
159        assert!(result.contains("After"));
160    }
161
162    #[test]
163    fn unordered_list() {
164        let input = "- first\n- second\n- third";
165        let result = render(input, &opts());
166        assert!(result.contains("- first"));
167        assert!(result.contains("- second"));
168        assert!(result.contains("- third"));
169    }
170
171    #[test]
172    fn ordered_list() {
173        let input = "1. first\n2. second\n3. third";
174        let result = render(input, &opts());
175        assert!(result.contains("1. first"));
176        assert!(result.contains("2. second"));
177        assert!(result.contains("3. third"));
178    }
179
180    #[test]
181    fn syntect_knows_toml() {
182        assert!(
183            has_syntax("toml"),
184            "syntect should have a TOML syntax definition"
185        );
186    }
187
188    #[test]
189    fn toml_code_block_highlights_keys_and_strings() {
190        let input = "```toml\n[package]\nname = \"test\"\n```";
191        let result = render(input, &opts());
192        let ansi_count = result.matches("\x1b[38;2;").count();
193        assert!(
194            ansi_count >= 2,
195            "TOML highlighting should produce multiple color codes, got {ansi_count}"
196        );
197    }
198
199    #[test]
200    fn render_inline_strips_paragraph() {
201        let result = render_inline("hello world", &opts());
202        assert_eq!(result, "hello world");
203    }
204
205    #[test]
206    fn list_items_on_separate_lines() {
207        let input = "- first\n- second\n- third";
208        let result = render(input, &opts());
209        assert_eq!(result, "- first\n- second\n- third\n");
210    }
211
212    #[test]
213    fn loose_list_items_on_separate_lines() {
214        let input = "- first\n\n- second\n\n- third";
215        let result = render(input, &opts());
216        assert_eq!(result, "- first\n- second\n- third\n");
217    }
218
219    #[test]
220    fn list_separated_from_paragraphs() {
221        let input = "Some text.\n\n- item one\n- item two\n\nMore text.";
222        let result = render(input, &opts());
223        assert_eq!(
224            result,
225            "Some text.\n\n- item one\n- item two\n\nMore text.\n"
226        );
227    }
228
229    #[test]
230    fn code_block_followed_by_text() {
231        let input = "```json\n{}\n```\n\nAfter code.";
232        let result = render(input, &opts());
233        assert!(
234            result.contains("\n\nAfter code."),
235            "Should have blank line after code block, got: {result:?}"
236        );
237    }
238
239    #[test]
240    fn paragraph_wrapping() {
241        let input =
242            "This is a long paragraph that should be wrapped at a specific width for display.";
243        let result = render(
244            input,
245            &Options {
246                syntax_highlight: true,
247                width: Some(30),
248                code_bg: true,
249            },
250        );
251        for line in result.trim_end().split('\n') {
252            assert!(
253                line.len() <= 30,
254                "Line should be at most 30 chars, got {} chars: {line:?}",
255                line.len()
256            );
257        }
258        assert!(result.contains("This"));
259        assert!(result.contains("display."));
260    }
261
262    #[test]
263    fn list_item_wrapping_accounts_for_prefix() {
264        let input = "- aaa bbb ccc ddd eee";
265        let result = render(
266            input,
267            &Options {
268                syntax_highlight: true,
269                width: Some(20),
270                code_bg: true,
271            },
272        );
273        for line in result.trim_end().split('\n') {
274            assert!(
275                line.len() <= 20,
276                "Line should be at most 20 chars, got {} chars: {line:?}",
277                line.len()
278            );
279        }
280        let lines: Vec<&str> = result.trim_end().split('\n').collect();
281        assert!(
282            lines[0].starts_with("- "),
283            "First line should start with '- ', got: {:?}",
284            lines[0]
285        );
286        if lines.len() > 1 {
287            assert!(
288                lines[1].starts_with("  "),
289                "Continuation line should start with 2-space indent, got: {:?}",
290                lines[1]
291            );
292        }
293    }
294
295    #[test]
296    fn table_renders_with_borders() {
297        let input = "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |";
298        let result = render(input, &opts());
299        assert!(
300            result.contains("Alice"),
301            "Table should contain cell content"
302        );
303        assert!(result.contains("Bob"), "Table should contain cell content");
304        assert!(result.contains('┌'), "Table should have top-left corner");
305        assert!(
306            result.contains('┘'),
307            "Table should have bottom-right corner"
308        );
309        assert!(result.contains('│'), "Table should have vertical borders");
310        assert!(result.contains('─'), "Table should have horizontal borders");
311    }
312
313    #[test]
314    fn table_header_is_bold() {
315        let input = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
316        let result = render(input, &opts());
317        assert!(
318            result.contains(BOLD),
319            "Table header should be bold, got: {result:?}"
320        );
321    }
322
323    #[test]
324    fn table_with_alignment() {
325        let input = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |";
326        let result = render(input, &opts());
327        assert!(result.contains("Left"), "Should contain header text");
328        assert!(result.contains("Center"), "Should contain header text");
329        assert!(result.contains("Right"), "Should contain header text");
330    }
331
332    #[test]
333    fn table_with_links_renders_aligned() {
334        let input = "| Name | Link |\n| --- | --- |\n| Alice | [docs](https://example.com) |\n| Bob | plain |";
335        let result = render(input, &opts());
336        // Table should render without panicking and contain all content.
337        assert!(result.contains("Alice"), "Should contain Alice");
338        assert!(result.contains("docs"), "Should contain link text");
339        assert!(result.contains("Bob"), "Should contain Bob");
340        assert!(result.contains("plain"), "Should contain plain text");
341        // All rows should have consistent border characters.
342        let pipe_counts: Vec<usize> = result
343            .lines()
344            .filter(|l| l.contains('│'))
345            .map(|l| l.chars().filter(|&c| c == '│').count())
346            .collect();
347        assert!(
348            pipe_counts.iter().all(|&c| c == pipe_counts[0]),
349            "All data rows should have the same number of │ borders, got: {pipe_counts:?}"
350        );
351    }
352}