Skip to main content

smart_markdown/
lib.rs

1mod elements;
2mod parser;
3mod renderer;
4#[cfg(feature = "syntax-highlight")]
5pub mod highlight;
6
7use elements::*;
8use parser::parse_document;
9use renderer::render_element;
10
11#[cfg(feature = "syntax-highlight")]
12pub use highlight::ThemeMode;
13
14#[cfg(not(feature = "syntax-highlight"))]
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ThemeMode { Dark, Light, Auto }
17
18pub struct Markdown {
19    elements: Vec<MarkdownElement>,
20    last_width: usize,
21    last_rendered_lines: usize,
22    has_rendered: bool,
23    theme_mode: ThemeMode,
24    code_theme: Option<String>,
25}
26
27impl Markdown {
28    pub fn parse(text: &str) -> Self {
29        Markdown {
30            elements: parse_document(text),
31            last_width: 0,
32            last_rendered_lines: 0,
33            has_rendered: false,
34            theme_mode: ThemeMode::Auto,
35            code_theme: None,
36        }
37    }
38
39    pub fn theme_mode(mut self, mode: ThemeMode) -> Self {
40        self.theme_mode = mode;
41        self
42    }
43
44    pub fn code_theme(mut self, theme: &str) -> Self {
45        self.code_theme = Some(theme.to_string());
46        self
47    }
48
49    pub fn has_terminal_resized(&self) -> bool {
50        let current = current_terminal_width();
51        current != self.last_width && self.last_width > 0
52    }
53
54    pub fn append_to_cell(&mut self, row: usize, col: usize, text: &str) {
55        for elem in &mut self.elements {
56            if let MarkdownElement::Table(td) = elem {
57                if row < td.rows.len() && col < td.headers.len() {
58                    td.rows[row][col].push_str(text);
59                }
60                return;
61            }
62        }
63    }
64
65    pub fn set_cell_content(&mut self, row: usize, col: usize, text: &str) {
66        for elem in &mut self.elements {
67            if let MarkdownElement::Table(td) = elem {
68                if row < td.rows.len() && col < td.headers.len() {
69                    td.rows[row][col] = text.to_string();
70                }
71                return;
72            }
73        }
74    }
75
76    pub fn render(&mut self) {
77        let width = current_terminal_width();
78        let mode = self.theme_mode;
79        let mut output: Vec<String> = Vec::new();
80
81        for elem in &self.elements {
82            let lines = render_element(elem, width, mode, self.code_theme.as_deref());
83            output.extend(lines);
84        }
85
86        let new_line_count = output.len();
87
88        if self.has_rendered {
89            print!("\x1b[{}A", self.last_rendered_lines);
90        }
91
92        for line in &output {
93            if self.has_rendered {
94                print!("\x1b[2K\r");
95            }
96            println!("{line}");
97        }
98
99        if self.has_rendered && new_line_count < self.last_rendered_lines {
100            for _ in new_line_count..self.last_rendered_lines {
101                print!("\x1b[2K\r");
102                println!();
103            }
104            if self.last_rendered_lines > new_line_count {
105                print!("\x1b[{}A", self.last_rendered_lines.saturating_sub(new_line_count));
106            }
107        }
108
109        self.last_rendered_lines = new_line_count;
110        self.last_width = width;
111        self.has_rendered = true;
112    }
113}
114
115fn current_terminal_width() -> usize {
116    terminal_size::terminal_size()
117        .map(|(w, _)| w.0 as usize)
118        .unwrap_or(80)
119}
120
121pub fn render_to_string(markdown: &str, width: usize) -> String {
122    let elements = parse_document(markdown);
123    let mut output: Vec<String> = Vec::new();
124    for elem in &elements {
125        output.extend(render_element(elem, width, ThemeMode::Auto, None));
126    }
127    output.join("\n")
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn parse_heading_setext_level_1() {
136        let result = render_to_string("Hello\n=====\n", 40);
137        assert!(result.contains("Hello"));
138        assert!(result.contains("◆"));
139    }
140
141    #[test]
142    fn parse_heading_setext_level_2() {
143        let result = render_to_string("Hello\n-----\n", 40);
144        assert!(result.contains("Hello"));
145        assert!(result.contains("●"));
146    }
147
148    #[test]
149    fn parse_heading_atx() {
150        let result = render_to_string("### Level 3\n", 40);
151        assert!(result.contains("Level 3"));
152        assert!(result.contains("▼"));
153    }
154
155    #[test]
156    fn parse_bold_and_italic() {
157        let result = render_to_string("**bold** and *italic*\n", 40);
158        assert!(result.contains("\x1b[1mbold\x1b[0m"));
159        assert!(result.contains("\x1b[3mitalic\x1b[0m"));
160    }
161
162    #[test]
163    fn parse_strikethrough() {
164        let result = render_to_string("~~deleted~~\n", 40);
165        assert!(result.contains("\x1b[9mdeleted\x1b[0m"));
166    }
167
168    #[test]
169    fn parse_inline_code() {
170        let result = render_to_string("`code`\n", 40);
171        assert!(result.contains("\x1b[7m code \x1b[0m"));
172    }
173
174    #[test]
175    fn parse_link() {
176        let result = render_to_string("[example](https://example.com)\n", 40);
177        assert!(result.contains("\x1b[4mexample\x1b[0m"));
178    }
179
180    #[test]
181    fn parse_unordered_list() {
182        let result = render_to_string("- one\n- two\n- three\n", 40);
183        assert_eq!(result.lines().filter(|l| l.starts_with("•")).count(), 3);
184    }
185
186    #[test]
187    fn parse_ordered_list() {
188        let result = render_to_string("1. first\n2. second\n3. third\n", 40);
189        assert_eq!(result.lines().filter(|l| l.starts_with("1.")).count(), 1);
190        assert_eq!(result.lines().filter(|l| l.starts_with("2.")).count(), 1);
191    }
192
193    #[test]
194    fn parse_table() {
195        let result = render_to_string("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n", 80);
196        assert!(result.contains("│ a"));
197        assert!(result.contains("│ 1"));
198    }
199
200    #[test]
201    fn parse_code_block() {
202        let result = render_to_string("```\nlet x = 1;\n```\n", 80);
203        assert!(result.contains("let x = 1;"));
204    }
205
206    #[test]
207    fn parse_blockquote() {
208        let result = render_to_string("> quoted text here\n", 40);
209        assert!(result.contains("quoted text here"));
210    }
211
212    #[test]
213    fn parse_horizontal_rule() {
214        let result = render_to_string("---\n", 40);
215        assert!(result.starts_with("─"));
216    }
217
218    #[test]
219    fn markdown_parse_and_streaming() {
220        let mut md = Markdown::parse("| col |\n|-----|\n| a   |\n");
221        md.append_to_cell(0, 0, "ppended");
222        let after = render_to_string("| col |\n|-----|\n| appended |\n", 80);
223        assert!(after.contains("appended"));
224    }
225
226    #[test]
227    fn set_cell_content_replaces() {
228        let mut md = Markdown::parse("| col |\n|-----|\n| old |\n");
229        md.set_cell_content(0, 0, "new");
230        let result = render_to_string("| col |\n|-----|\n| new |\n", 80);
231        assert!(result.contains("new"));
232        assert!(!result.contains("old"));
233    }
234
235    #[test]
236    fn table_fill_column() {
237        let result = render_to_string("| a |  |\n|---|---|\n| 1 |  |\n", 100);
238        assert!(result.contains("│ a"));
239    }
240
241    #[test]
242    fn table_alignment_center() {
243        let result = render_to_string("| a |\n|:---:|\n| 1 |\n", 80);
244        assert!(result.contains("│"));
245    }
246
247    #[test]
248    fn table_alignment_right() {
249        let result = render_to_string("| a |\n|---:|\n| 1 |\n", 80);
250        assert!(result.contains("│"));
251    }
252
253    #[test]
254    fn paragraph_soft_wrap() {
255        let long = "a ".repeat(50);
256        let result = render_to_string(&format!("{long}\n"), 40);
257        assert!(result.contains('\n'));
258    }
259
260    #[test]
261    fn blank_line_preserved() {
262        let result = render_to_string("para 1\n\npara 2\n", 40);
263        let empties = result.lines().filter(|l| l.is_empty()).count();
264        assert!(empties >= 1);
265    }
266
267    #[test]
268    fn parse_reference_link() {
269        let result = render_to_string("[text][ref]\n\n[ref]: https://example.com\n", 80);
270        assert!(result.contains("\x1b[4mtext\x1b[0m"));
271    }
272
273    #[test]
274    fn parse_reference_link_implicit() {
275        let result = render_to_string("[text][]\n\n[text]: https://example.com\n", 80);
276        assert!(result.contains("\x1b[4mtext\x1b[0m"));
277    }
278
279    #[test]
280    fn reference_link_case_insensitive() {
281        let result = render_to_string("[text][REF]\n\n[ref]: https://example.com\n", 80);
282        assert!(result.contains("\x1b[4mtext\x1b[0m"));
283    }
284}