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:
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("*this is **bold** text") {
41//!     println!("{line}");
42//! }
43//! for line in sr.flush_remaining() {
44//!     println!("{line}");
45//! }
46//! ```
47
48mod 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
68/// A parsed Markdown document with support for live in-place terminal rendering.
69///
70/// `Markdown` parses markdown text into an internal element tree, then renders it
71/// with ANSI escape codes. It tracks line counts between renders so subsequent
72/// calls to [`render`](Self::render) overwrite the previous output in-place
73/// using terminal cursor control sequences.
74///
75/// # Examples
76///
77/// ```rust
78/// use smart_markdown::{Markdown, ThemeMode};
79///
80/// let mut md = Markdown::parse("## Status\n\n**Running...**")
81///     .theme_mode(ThemeMode::Dark)
82///     .code_theme("base16-ocean.dark");
83///
84/// md.render(); // outputs to stdout
85///
86/// // Update a table cell and re-render
87/// md.set_cell_content(0, 1, "new data");
88/// md.render(); // overwrites previous output in-place
89/// ```
90pub 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    /// Parse a markdown string into a `Markdown` document.
101    ///
102    /// Supports: headings (ATX and setext), paragraphs, fenced/indented code blocks,
103    /// blockquotes, ordered/unordered/task lists, tables, definition lists,
104    /// horizontal rules, HTML blocks, footnotes, and reference links.
105    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    /// Set the syntax highlighting theme mode.
117    ///
118    /// When the `syntax-highlight` feature is enabled (on by default),
119    /// this controls which syntect color theme is used for fenced code blocks.
120    /// - `ThemeMode::Dark` — "Base16 Eighties Dark"
121    /// - `ThemeMode::Light` — "Solarized (light)"
122    /// - `ThemeMode::Auto` — detects terminal background color at render time
123    pub fn theme_mode(mut self, mode: ThemeMode) -> Self {
124        self.theme_mode = mode;
125        self
126    }
127
128    /// Override the syntax highlighting theme by name.
129    ///
130    /// See [`highlight::list_themes`] for available theme names.
131    /// When the `syntax-highlight` feature is disabled, this has no effect.
132    pub fn code_theme(mut self, theme: &str) -> Self {
133        self.code_theme = Some(theme.to_string());
134        self
135    }
136
137    /// Returns `true` if the terminal width has changed since the last render.
138    ///
139    /// Useful for deciding whether to re-render on SIGWINCH.
140    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    /// Append text to a table cell identified by row and column index.
146    ///
147    /// Panics if the document doesn't contain a table or the indices are out of bounds.
148    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    /// Replace the content of 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 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    /// Render the document to stdout with ANSI escape codes.
174    ///
175    /// On first call, output is simply printed. On subsequent calls, the previous
176    /// output is overwritten in-place using cursor-up and line-clear sequences.
177    /// Lines that shrink between renders are properly cleared.
178    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
217/// Render a markdown string to a single `String` with ANSI styling.
218///
219/// This is a stateless convenience function that parses the input, renders
220/// each element, and joins with `\n`. For incremental rendering with terminal
221/// cursor control, use [`Markdown::render`] or [`StreamRenderer`].
222///
223/// Always uses `ThemeMode::Auto` and no custom code theme.
224pub 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
239/// Returns `true` if the terminal background is light-colored.
240///
241/// Checks the `COLORFGBG` environment variable (set by many terminal
242/// emulators). Returns `false` if the variable is unset or unparseable,
243/// defaulting to a dark-background assumption.
244pub 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}