Skip to main content

smart_markdown/
lib.rs

1//! Parse and render Markdown to ANSI-styled terminal output.
2//!
3//! # Quick start
4//!
5//! One-shot rendering to a string:
6//!
7//! ```rust
8//! use smart_markdown::render_to_string;
9//!
10//! let output = render_to_string("# Hello\n\n**bold** and *italic*\n", 80);
11//! println!("{output}");
12//! ```
13//!
14//! Live in-place terminal rendering with cursor control:
15//!
16//! ```rust
17//! use smart_markdown::Markdown;
18//!
19//! let mut md = Markdown::parse("# Loading...\n\n*please wait*");
20//! md.render(); // prints to stdout with ANSI escape codes
21//!
22//! // Update content and re-render in-place
23//! // (requires modifying parsed elements — see append_to_cell, set_cell_content)
24//! ```
25//!
26//! Streaming LLM output with real-time table updates:
27//!
28//! ```rust
29//! use smart_markdown::{StreamRenderer, ThemeMode, is_light_terminal};
30//!
31//! let width = 80;
32//! let theme = if is_light_terminal() { ThemeMode::Light } else { ThemeMode::Dark };
33//! let mut sr = StreamRenderer::new(width, theme)
34//!     .with_ascii_table_borders(true);
35//!
36//! // Feed chunks as they arrive from the LLM
37//! for line in sr.push("# Hello\n\n") {
38//!     println!("{line}");
39//! }
40//! for line in sr.push("| Name | Value |\n") {
41//!     println!("{line}");
42//! }
43//! for line in sr.push("|------|-------|\n") {
44//!     println!("{line}");
45//! }
46//! // Table will render and re-render as new rows are added
47//! for line in sr.push("| CPU  | 45%   |\n") {
48//!     println!("{line}");
49//! }
50//! for line in sr.push("| Mem  | 67%   |\n") {
51//!     println!("{line}");
52//! }
53//! for line in sr.flush_remaining() {
54//!     println!("{line}");
55//! }
56//! ```
57
58mod 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
82/// A parsed Markdown document with support for live in-place terminal rendering.
83///
84/// `Markdown` parses markdown text into an internal element tree, then renders it
85/// with ANSI escape codes. It tracks line counts between renders so subsequent
86/// calls to [`render`](Self::render) overwrite the previous output in-place
87/// using terminal cursor control sequences.
88///
89/// # Examples
90///
91/// ```rust
92/// use smart_markdown::{Markdown, ThemeMode};
93///
94/// let mut md = Markdown::parse("## Status\n\n**Running...**")
95///     .theme_mode(ThemeMode::Dark)
96///     .code_theme("base16-ocean.dark");
97///
98/// md.render(); // outputs to stdout
99///
100/// // Update a table cell and re-render
101/// md.set_cell_content(0, 1, "new data");
102/// md.render(); // overwrites previous output in-place
103/// ```
104pub 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    /// Parse a markdown string into a `Markdown` document.
115    ///
116    /// Supports: headings (ATX and setext), paragraphs, fenced/indented code blocks,
117    /// blockquotes, ordered/unordered/task lists, tables, definition lists,
118    /// horizontal rules, HTML blocks, footnotes, and reference links.
119    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    /// Set the syntax highlighting theme mode.
131    ///
132    /// When the `syntax-highlight` feature is enabled (on by default),
133    /// this controls which syntect color theme is used for fenced code blocks.
134    /// - `ThemeMode::Dark` — "Base16 Eighties Dark"
135    /// - `ThemeMode::Light` — "Solarized (light)"
136    /// - `ThemeMode::Auto` — detects terminal background color at render time
137    pub fn theme_mode(mut self, mode: ThemeMode) -> Self {
138        self.theme_mode = mode;
139        self
140    }
141
142    /// Override the syntax highlighting theme by name.
143    ///
144    /// See [`highlight::list_themes`] for available theme names.
145    /// When the `syntax-highlight` feature is disabled, this has no effect.
146    pub fn code_theme(mut self, theme: &str) -> Self {
147        self.code_theme = Some(theme.to_string());
148        self
149    }
150
151    /// Returns `true` if the terminal width has changed since the last render.
152    ///
153    /// Useful for deciding whether to re-render on SIGWINCH.
154    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    /// Append text to a table cell identified by row and column index.
160    ///
161    /// Panics if the document doesn't contain a table or the indices are out of bounds.
162    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    /// Replace the content of a table cell identified by row and column index.
174    ///
175    /// Panics if the document doesn't contain a table or the indices are out of bounds.
176    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    /// Render the document to stdout with ANSI escape codes.
188    ///
189    /// On first call, output is simply printed. On subsequent calls, the previous
190    /// output is overwritten in-place using cursor-up and line-clear sequences.
191    /// Lines that shrink between renders are properly cleared.
192    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
234/// Render a markdown string to a single `String` with ANSI styling.
235///
236/// This is a stateless convenience function that parses the input, renders
237/// each element, and joins with `\n`. For incremental rendering with terminal
238/// cursor control, use [`Markdown::render`] or [`StreamRenderer`].
239///
240/// Always uses `ThemeMode::Auto` and no custom code theme.
241pub 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
256/// Returns `true` if the terminal background is light-colored.
257///
258/// Checks the `COLORFGBG` environment variable (set by many terminal
259/// emulators). Returns `false` if the variable is unset or unparseable,
260/// defaulting to a dark-background assumption.
261pub 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}