1mod elements;
49mod parser;
50mod renderer;
51mod stream_renderer;
52#[cfg(feature = "syntax-highlight")]
53pub mod highlight;
54
55use elements::*;
56use parser::parse_document;
57use renderer::render_element;
58
59pub use stream_renderer::StreamRenderer;
60
61#[cfg(feature = "syntax-highlight")]
62pub use highlight::ThemeMode;
63
64#[cfg(not(feature = "syntax-highlight"))]
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ThemeMode { Dark, Light, Auto }
67
68pub struct Markdown {
91 elements: Vec<MarkdownElement>,
92 last_width: usize,
93 last_rendered_lines: usize,
94 has_rendered: bool,
95 theme_mode: ThemeMode,
96 code_theme: Option<String>,
97}
98
99impl Markdown {
100 pub fn parse(text: &str) -> Self {
106 Markdown {
107 elements: parse_document(text),
108 last_width: 0,
109 last_rendered_lines: 0,
110 has_rendered: false,
111 theme_mode: ThemeMode::Auto,
112 code_theme: None,
113 }
114 }
115
116 pub fn theme_mode(mut self, mode: ThemeMode) -> Self {
124 self.theme_mode = mode;
125 self
126 }
127
128 pub fn code_theme(mut self, theme: &str) -> Self {
133 self.code_theme = Some(theme.to_string());
134 self
135 }
136
137 pub fn has_terminal_resized(&self) -> bool {
141 let current = current_terminal_width();
142 current != self.last_width && self.last_width > 0
143 }
144
145 pub fn append_to_cell(&mut self, row: usize, col: usize, text: &str) {
149 for elem in &mut self.elements {
150 if let MarkdownElement::Table(td) = elem {
151 if row < td.rows.len() && col < td.headers.len() {
152 td.rows[row][col].push_str(text);
153 }
154 return;
155 }
156 }
157 }
158
159 pub fn set_cell_content(&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] = text.to_string();
167 }
168 return;
169 }
170 }
171 }
172
173 pub fn render(&mut self) {
179 let width = current_terminal_width();
180 let mode = self.theme_mode;
181 let mut output: Vec<String> = Vec::new();
182
183 for elem in &self.elements {
184 let lines = render_element(elem, width, mode, self.code_theme.as_deref());
185 output.extend(lines);
186 }
187
188 let new_line_count = output.len();
189
190 if self.has_rendered {
191 print!("\x1b[{}A", self.last_rendered_lines);
192 }
193
194 for line in &output {
195 if self.has_rendered {
196 print!("\x1b[2K\r");
197 }
198 println!("{line}");
199 }
200
201 if self.has_rendered && new_line_count < self.last_rendered_lines {
202 for _ in new_line_count..self.last_rendered_lines {
203 print!("\x1b[2K\r");
204 println!();
205 }
206 if self.last_rendered_lines > new_line_count {
207 print!("\x1b[{}A", self.last_rendered_lines.saturating_sub(new_line_count));
208 }
209 }
210
211 self.last_rendered_lines = new_line_count;
212 self.last_width = width;
213 self.has_rendered = true;
214 }
215}
216
217pub fn render_to_string(markdown: &str, width: usize) -> String {
225 let elements = parse_document(markdown);
226 let mut output: Vec<String> = Vec::new();
227 for elem in &elements {
228 output.extend(render_element(elem, width, ThemeMode::Auto, None));
229 }
230 output.join("\n")
231}
232
233fn current_terminal_width() -> usize {
234 terminal_size::terminal_size()
235 .map(|(w, _)| w.0 as usize)
236 .unwrap_or(80)
237}
238
239pub fn is_light_terminal() -> bool {
245 if let Ok(colorfgbg) = std::env::var("COLORFGBG") {
246 let parts: Vec<&str> = colorfgbg.split(';').collect();
247 if let Some(bg) = parts.get(1)
248 && let Ok(bg_num) = bg.parse::<u8>()
249 {
250 return bg_num >= 7;
251 }
252 }
253 false
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn parse_heading_setext_level_1() {
262 let result = render_to_string("Hello\n=====\n", 40);
263 assert!(result.contains("Hello"));
264 assert!(result.contains("◆"));
265 }
266
267 #[test]
268 fn parse_heading_setext_level_2() {
269 let result = render_to_string("Hello\n-----\n", 40);
270 assert!(result.contains("Hello"));
271 assert!(result.contains("●"));
272 }
273
274 #[test]
275 fn parse_heading_atx() {
276 let result = render_to_string("### Level 3\n", 40);
277 assert!(result.contains("Level 3"));
278 assert!(result.contains("▼"));
279 }
280
281 #[test]
282 fn parse_bold_and_italic() {
283 let result = render_to_string("**bold** and *italic*\n", 40);
284 assert!(result.contains("\x1b[1mbold\x1b[0m"));
285 assert!(result.contains("\x1b[3mitalic\x1b[0m"));
286 }
287
288 #[test]
289 fn parse_strikethrough() {
290 let result = render_to_string("~~deleted~~\n", 40);
291 assert!(result.contains("\x1b[9mdeleted\x1b[0m"));
292 }
293
294 #[test]
295 fn parse_inline_code() {
296 let result = render_to_string("`code`\n", 40);
297 assert!(result.contains("\x1b[7m code \x1b[0m"));
298 }
299
300 #[test]
301 fn parse_link() {
302 let result = render_to_string("[example](https://example.com)\n", 40);
303 assert!(result.contains("\x1b[4mexample\x1b[0m"));
304 }
305
306 #[test]
307 fn parse_unordered_list() {
308 let result = render_to_string("- one\n- two\n- three\n", 40);
309 assert_eq!(result.lines().filter(|l| l.starts_with("•")).count(), 3);
310 }
311
312 #[test]
313 fn parse_ordered_list() {
314 let result = render_to_string("1. first\n2. second\n3. third\n", 40);
315 assert_eq!(result.lines().filter(|l| l.starts_with("1.")).count(), 1);
316 assert_eq!(result.lines().filter(|l| l.starts_with("2.")).count(), 1);
317 }
318
319 #[test]
320 fn parse_table() {
321 let result = render_to_string("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n", 80);
322 assert!(result.contains("│ a"));
323 assert!(result.contains("│ 1"));
324 }
325
326 #[test]
327 fn parse_code_block() {
328 let result = render_to_string("```\nlet x = 1;\n```\n", 80);
329 assert!(result.contains("let x = 1;"));
330 }
331
332 #[test]
333 fn parse_blockquote() {
334 let result = render_to_string("> quoted text here\n", 40);
335 assert!(result.contains("quoted text here"));
336 }
337
338 #[test]
339 fn parse_horizontal_rule() {
340 let result = render_to_string("---\n", 40);
341 assert!(result.starts_with("─"));
342 }
343
344 #[test]
345 fn markdown_parse_and_streaming() {
346 let mut md = Markdown::parse("| col |\n|-----|\n| a |\n");
347 md.append_to_cell(0, 0, "ppended");
348 let after = render_to_string("| col |\n|-----|\n| appended |\n", 80);
349 assert!(after.contains("appended"));
350 }
351
352 #[test]
353 fn set_cell_content_replaces() {
354 let mut md = Markdown::parse("| col |\n|-----|\n| old |\n");
355 md.set_cell_content(0, 0, "new");
356 let result = render_to_string("| col |\n|-----|\n| new |\n", 80);
357 assert!(result.contains("new"));
358 assert!(!result.contains("old"));
359 }
360
361 #[test]
362 fn table_fill_column() {
363 let result = render_to_string("| a | |\n|---|---|\n| 1 | |\n", 100);
364 assert!(result.contains("│ a"));
365 }
366
367 #[test]
368 fn table_alignment_center() {
369 let result = render_to_string("| a |\n|:---:|\n| 1 |\n", 80);
370 assert!(result.contains("│"));
371 }
372
373 #[test]
374 fn table_alignment_right() {
375 let result = render_to_string("| a |\n|---:|\n| 1 |\n", 80);
376 assert!(result.contains("│"));
377 }
378
379 #[test]
380 fn paragraph_soft_wrap() {
381 let long = "a ".repeat(50);
382 let result = render_to_string(&format!("{long}\n"), 40);
383 assert!(result.contains('\n'));
384 }
385
386 #[test]
387 fn blank_line_preserved() {
388 let result = render_to_string("para 1\n\npara 2\n", 40);
389 let empties = result.lines().filter(|l| l.is_empty()).count();
390 assert!(empties >= 1);
391 }
392
393 #[test]
394 fn parse_reference_link() {
395 let result = render_to_string("[text][ref]\n\n[ref]: https://example.com\n", 80);
396 assert!(result.contains("\x1b[4mtext\x1b[0m"));
397 }
398
399 #[test]
400 fn parse_reference_link_implicit() {
401 let result = render_to_string("[text][]\n\n[text]: https://example.com\n", 80);
402 assert!(result.contains("\x1b[4mtext\x1b[0m"));
403 }
404
405 #[test]
406 fn reference_link_case_insensitive() {
407 let result = render_to_string("[text][REF]\n\n[ref]: https://example.com\n", 80);
408 assert!(result.contains("\x1b[4mtext\x1b[0m"));
409 }
410}