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}