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
11pub struct Options {
13 pub syntax_highlight: bool,
15 pub width: Option<usize>,
18 pub code_bg: bool,
25}
26
27pub 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
34pub 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 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 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 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}