1mod elements;
59#[cfg(feature = "syntax-highlight")]
60pub mod highlight;
61mod parser;
62mod renderer;
63mod stream_renderer;
64
65use elements::*;
66use parser::parse_document;
67use renderer::render_element;
68
69pub use stream_renderer::StreamRenderer;
70
71#[cfg(feature = "syntax-highlight")]
72pub use highlight::ThemeMode;
73
74#[cfg(not(feature = "syntax-highlight"))]
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum ThemeMode {
77 Dark,
78 Light,
79 Auto,
80}
81
82pub struct Markdown {
105 elements: Vec<MarkdownElement>,
106 last_width: usize,
107 last_rendered_lines: usize,
108 has_rendered: bool,
109 theme_mode: ThemeMode,
110 code_theme: Option<String>,
111}
112
113impl Markdown {
114 pub fn parse(text: &str) -> Self {
120 Markdown {
121 elements: parse_document(text),
122 last_width: 0,
123 last_rendered_lines: 0,
124 has_rendered: false,
125 theme_mode: ThemeMode::Auto,
126 code_theme: None,
127 }
128 }
129
130 pub fn theme_mode(mut self, mode: ThemeMode) -> Self {
138 self.theme_mode = mode;
139 self
140 }
141
142 pub fn code_theme(mut self, theme: &str) -> Self {
147 self.code_theme = Some(theme.to_string());
148 self
149 }
150
151 pub fn has_terminal_resized(&self) -> bool {
155 let current = current_terminal_width();
156 current != self.last_width && self.last_width > 0
157 }
158
159 pub fn append_to_cell(&mut self, row: usize, col: usize, text: &str) {
163 for elem in &mut self.elements {
164 if let MarkdownElement::Table(td) = elem {
165 if row < td.rows.len() && col < td.headers.len() {
166 td.rows[row][col].push_str(text);
167 }
168 return;
169 }
170 }
171 }
172
173 pub fn set_cell_content(&mut self, row: usize, col: usize, text: &str) {
177 for elem in &mut self.elements {
178 if let MarkdownElement::Table(td) = elem {
179 if row < td.rows.len() && col < td.headers.len() {
180 td.rows[row][col] = text.to_string();
181 }
182 return;
183 }
184 }
185 }
186
187 pub fn render(&mut self) {
193 let width = current_terminal_width();
194 let mode = self.theme_mode;
195 let mut output: Vec<String> = Vec::new();
196
197 for elem in &self.elements {
198 let lines = render_element(elem, width, mode, self.code_theme.as_deref());
199 output.extend(lines);
200 }
201
202 let new_line_count = output.len();
203
204 if self.has_rendered {
205 print!("\x1b[{}A", self.last_rendered_lines);
206 }
207
208 for line in &output {
209 if self.has_rendered {
210 print!("\x1b[2K\r");
211 }
212 println!("{line}");
213 }
214
215 if self.has_rendered && new_line_count < self.last_rendered_lines {
216 for _ in new_line_count..self.last_rendered_lines {
217 print!("\x1b[2K\r");
218 println!();
219 }
220 if self.last_rendered_lines > new_line_count {
221 print!(
222 "\x1b[{}A",
223 self.last_rendered_lines.saturating_sub(new_line_count)
224 );
225 }
226 }
227
228 self.last_rendered_lines = new_line_count;
229 self.last_width = width;
230 self.has_rendered = true;
231 }
232}
233
234pub fn render_to_string(markdown: &str, width: usize) -> String {
242 let elements = parse_document(markdown);
243 let mut output: Vec<String> = Vec::new();
244 for elem in &elements {
245 output.extend(render_element(elem, width, ThemeMode::Auto, None));
246 }
247 output.join("\n")
248}
249
250fn current_terminal_width() -> usize {
251 terminal_size::terminal_size()
252 .map(|(w, _)| w.0 as usize)
253 .unwrap_or(80)
254}
255
256pub fn is_light_terminal() -> bool {
262 if let Ok(colorfgbg) = std::env::var("COLORFGBG") {
263 let parts: Vec<&str> = colorfgbg.split(';').collect();
264 if let Some(bg) = parts.get(1)
265 && let Ok(bg_num) = bg.parse::<u8>()
266 {
267 return bg_num >= 7;
268 }
269 }
270 false
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn parse_heading_setext_level_1() {
279 let result = render_to_string("Hello\n=====\n", 40);
280 assert!(result.contains("Hello"));
281 assert!(result.contains("◆"));
282 }
283
284 #[test]
285 fn parse_heading_setext_level_2() {
286 let result = render_to_string("Hello\n-----\n", 40);
287 assert!(result.contains("Hello"));
288 assert!(result.contains("●"));
289 }
290
291 #[test]
292 fn parse_heading_atx() {
293 let result = render_to_string("### Level 3\n", 40);
294 assert!(result.contains("Level 3"));
295 assert!(result.contains("▼"));
296 }
297
298 #[test]
299 fn parse_bold_and_italic() {
300 let result = render_to_string("**bold** and *italic*\n", 40);
301 assert!(result.contains("\x1b[1mbold\x1b[0m"));
302 assert!(result.contains("\x1b[3mitalic\x1b[0m"));
303 }
304
305 #[test]
306 fn parse_strikethrough() {
307 let result = render_to_string("~~deleted~~\n", 40);
308 assert!(result.contains("\x1b[9mdeleted\x1b[0m"));
309 }
310
311 #[test]
312 fn parse_inline_code() {
313 let result = render_to_string("`code`\n", 40);
314 assert!(result.contains("\x1b[7m code \x1b[0m"));
315 }
316
317 #[test]
318 fn parse_link() {
319 let result = render_to_string("[example](https://example.com)\n", 40);
320 assert!(result.contains("\x1b[4mexample\x1b[0m"));
321 }
322
323 #[test]
324 fn parse_unordered_list() {
325 let result = render_to_string("- one\n- two\n- three\n", 40);
326 assert_eq!(result.lines().filter(|l| l.starts_with("•")).count(), 3);
327 }
328
329 #[test]
330 fn parse_ordered_list() {
331 let result = render_to_string("1. first\n2. second\n3. third\n", 40);
332 assert_eq!(result.lines().filter(|l| l.starts_with("1.")).count(), 1);
333 assert_eq!(result.lines().filter(|l| l.starts_with("2.")).count(), 1);
334 }
335
336 #[test]
337 fn parse_table() {
338 let result = render_to_string("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n", 80);
339 assert!(result.contains("│ a"));
340 assert!(result.contains("│ 1"));
341 }
342
343 #[test]
344 fn parse_code_block() {
345 let result = render_to_string("```\nlet x = 1;\n```\n", 80);
346 assert!(result.contains("let x = 1;"));
347 }
348
349 #[test]
350 fn parse_blockquote() {
351 let result = render_to_string("> quoted text here\n", 40);
352 assert!(result.contains("quoted text here"));
353 }
354
355 #[test]
356 fn parse_horizontal_rule() {
357 let result = render_to_string("---\n", 40);
358 assert!(result.starts_with("─"));
359 }
360
361 #[test]
362 fn markdown_parse_and_streaming() {
363 let mut md = Markdown::parse("| col |\n|-----|\n| a |\n");
364 md.append_to_cell(0, 0, "ppended");
365 let after = render_to_string("| col |\n|-----|\n| appended |\n", 80);
366 assert!(after.contains("appended"));
367 }
368
369 #[test]
370 fn set_cell_content_replaces() {
371 let mut md = Markdown::parse("| col |\n|-----|\n| old |\n");
372 md.set_cell_content(0, 0, "new");
373 let result = render_to_string("| col |\n|-----|\n| new |\n", 80);
374 assert!(result.contains("new"));
375 assert!(!result.contains("old"));
376 }
377
378 #[test]
379 fn table_fill_column() {
380 let result = render_to_string("| a | |\n|---|---|\n| 1 | |\n", 100);
381 assert!(result.contains("│ a"));
382 }
383
384 #[test]
385 fn indented_table_render() {
386 let input = " | Name | Desc |\n |---|---|\n | A | B |\n";
387 let result = render_to_string(input, 80);
388 assert!(result.contains("┌"), "no top border: {result}");
389 assert!(result.contains("Name"), "no Name header: {result}");
390 assert!(result.contains("A"), "no data A: {result}");
391 }
392
393 #[test]
394 fn table_alignment_center() {
395 let result = render_to_string("| a |\n|:---:|\n| 1 |\n", 80);
396 assert!(result.contains("│"));
397 }
398
399 #[test]
400 fn table_alignment_right() {
401 let result = render_to_string("| a |\n|---:|\n| 1 |\n", 80);
402 assert!(result.contains("│"));
403 }
404
405 #[test]
406 fn paragraph_soft_wrap() {
407 let long = "a ".repeat(50);
408 let result = render_to_string(&format!("{long}\n"), 40);
409 assert!(result.contains('\n'));
410 }
411
412 #[test]
413 fn blank_line_preserved() {
414 let result = render_to_string("para 1\n\npara 2\n", 40);
415 let empties = result.lines().filter(|l| l.is_empty()).count();
416 assert!(empties >= 1);
417 }
418
419 #[test]
420 fn parse_reference_link() {
421 let result = render_to_string("[text][ref]\n\n[ref]: https://example.com\n", 80);
422 assert!(result.contains("\x1b[4mtext\x1b[0m"));
423 }
424
425 #[test]
426 fn parse_reference_link_implicit() {
427 let result = render_to_string("[text][]\n\n[text]: https://example.com\n", 80);
428 assert!(result.contains("\x1b[4mtext\x1b[0m"));
429 }
430
431 #[test]
432 fn reference_link_case_insensitive() {
433 let result = render_to_string("[text][REF]\n\n[ref]: https://example.com\n", 80);
434 assert!(result.contains("\x1b[4mtext\x1b[0m"));
435 }
436}