ricecoder_tui/
markdown.rs

1//! Markdown rendering for the TUI
2
3/// Markdown element types
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum MarkdownElement {
6    /// Plain text
7    Text(String),
8    /// Header (level, content)
9    Header(u8, String),
10    /// Bold text
11    Bold(String),
12    /// Italic text
13    Italic(String),
14    /// Code inline
15    Code(String),
16    /// Code block (language, content)
17    CodeBlock(Option<String>, String),
18    /// List item
19    ListItem(String),
20    /// Link (text, url)
21    Link(String, String),
22}
23
24/// Markdown parser
25pub struct MarkdownParser;
26
27impl MarkdownParser {
28    /// Parse markdown text into elements
29    pub fn parse(text: &str) -> Vec<MarkdownElement> {
30        let mut elements = Vec::new();
31        let lines: Vec<&str> = text.lines().collect();
32        let mut i = 0;
33
34        while i < lines.len() {
35            let line = lines[i];
36
37            // Check for code block
38            if let Some(after_backticks) = line.strip_prefix("```") {
39                let lang = after_backticks.trim().to_string();
40                let lang = if lang.is_empty() { None } else { Some(lang) };
41                let mut code = String::new();
42                i += 1;
43
44                while i < lines.len() && !lines[i].starts_with("```") {
45                    if !code.is_empty() {
46                        code.push('\n');
47                    }
48                    code.push_str(lines[i]);
49                    i += 1;
50                }
51
52                elements.push(MarkdownElement::CodeBlock(lang, code));
53                i += 1;
54                continue;
55            }
56
57            // Check for headers
58            if line.starts_with('#') {
59                let level = line.chars().take_while(|c| *c == '#').count() as u8;
60                let content = line[level as usize..].trim().to_string();
61                elements.push(MarkdownElement::Header(level, content));
62                i += 1;
63                continue;
64            }
65
66            // Check for list items
67            if line.starts_with("- ") || line.starts_with("* ") {
68                let content = line[2..].trim().to_string();
69                elements.push(MarkdownElement::ListItem(content));
70                i += 1;
71                continue;
72            }
73
74            // Parse inline elements
75            let parsed = Self::parse_inline(line);
76            elements.extend(parsed);
77            i += 1;
78        }
79
80        elements
81    }
82
83    /// Parse inline markdown elements
84    #[allow(clippy::while_let_on_iterator)]
85    fn parse_inline(text: &str) -> Vec<MarkdownElement> {
86        let mut elements = Vec::new();
87        let mut current = String::new();
88        let mut chars = text.chars().peekable();
89
90        while let Some(ch) = chars.next() {
91            match ch {
92                '*' | '_' => {
93                    if !current.is_empty() {
94                        elements.push(MarkdownElement::Text(current.clone()));
95                        current.clear();
96                    }
97
98                    // Check for bold or italic
99                    if chars.peek() == Some(&ch) {
100                        chars.next(); // consume second marker
101                        let mut content = String::new();
102                        let mut found = false;
103
104                        while let Some(c) = chars.next() {
105                            if c == ch && chars.peek() == Some(&ch) {
106                                chars.next();
107                                found = true;
108                                break;
109                            }
110                            content.push(c);
111                        }
112
113                        if found {
114                            elements.push(MarkdownElement::Bold(content));
115                        }
116                    } else {
117                        // Italic
118                        let mut content = String::new();
119                        let mut found = false;
120
121                        while let Some(c) = chars.next() {
122                            if c == ch {
123                                found = true;
124                                break;
125                            }
126                            content.push(c);
127                        }
128
129                        if found {
130                            elements.push(MarkdownElement::Italic(content));
131                        }
132                    }
133                }
134                '`' => {
135                    if !current.is_empty() {
136                        elements.push(MarkdownElement::Text(current.clone()));
137                        current.clear();
138                    }
139
140                    let mut content = String::new();
141                    while let Some(c) = chars.next() {
142                        if c == '`' {
143                            break;
144                        }
145                        content.push(c);
146                    }
147
148                    elements.push(MarkdownElement::Code(content));
149                }
150                '[' => {
151                    if !current.is_empty() {
152                        elements.push(MarkdownElement::Text(current.clone()));
153                        current.clear();
154                    }
155
156                    let mut link_text = String::new();
157                    while let Some(c) = chars.next() {
158                        if c == ']' {
159                            break;
160                        }
161                        link_text.push(c);
162                    }
163
164                    if chars.peek() == Some(&'(') {
165                        chars.next();
166                        let mut url = String::new();
167                        while let Some(c) = chars.next() {
168                            if c == ')' {
169                                break;
170                            }
171                            url.push(c);
172                        }
173
174                        elements.push(MarkdownElement::Link(link_text, url));
175                    }
176                }
177                _ => current.push(ch),
178            }
179        }
180
181        if !current.is_empty() {
182            elements.push(MarkdownElement::Text(current));
183        }
184
185        elements
186    }
187
188    /// Render markdown elements to plain text
189    pub fn render_plain(elements: &[MarkdownElement]) -> String {
190        let mut output = String::new();
191
192        for element in elements {
193            match element {
194                MarkdownElement::Text(text) => output.push_str(text),
195                MarkdownElement::Header(level, content) => {
196                    output.push_str(&"#".repeat(*level as usize));
197                    output.push(' ');
198                    output.push_str(content);
199                    output.push('\n');
200                }
201                MarkdownElement::Bold(text) => {
202                    output.push_str("**");
203                    output.push_str(text);
204                    output.push_str("**");
205                }
206                MarkdownElement::Italic(text) => {
207                    output.push('*');
208                    output.push_str(text);
209                    output.push('*');
210                }
211                MarkdownElement::Code(text) => {
212                    output.push('`');
213                    output.push_str(text);
214                    output.push('`');
215                }
216                MarkdownElement::CodeBlock(lang, code) => {
217                    output.push_str("```");
218                    if let Some(l) = lang {
219                        output.push_str(l);
220                    }
221                    output.push('\n');
222                    output.push_str(code);
223                    output.push_str("\n```\n");
224                }
225                MarkdownElement::ListItem(text) => {
226                    output.push_str("- ");
227                    output.push_str(text);
228                    output.push('\n');
229                }
230                MarkdownElement::Link(text, url) => {
231                    output.push('[');
232                    output.push_str(text);
233                    output.push_str("](");
234                    output.push_str(url);
235                    output.push(')');
236                }
237            }
238        }
239
240        output
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_parse_headers() {
250        let text = "# Header 1\n## Header 2\n### Header 3";
251        let elements = MarkdownParser::parse(text);
252
253        assert_eq!(elements.len(), 3);
254        assert!(matches!(elements[0], MarkdownElement::Header(1, _)));
255        assert!(matches!(elements[1], MarkdownElement::Header(2, _)));
256        assert!(matches!(elements[2], MarkdownElement::Header(3, _)));
257    }
258
259    #[test]
260    fn test_parse_code_block() {
261        let text = "```rust\nfn main() {}\n```";
262        let elements = MarkdownParser::parse(text);
263
264        assert_eq!(elements.len(), 1);
265        assert!(matches!(
266            elements[0],
267            MarkdownElement::CodeBlock(Some(_), _)
268        ));
269    }
270
271    #[test]
272    fn test_parse_list() {
273        let text = "- Item 1\n- Item 2\n- Item 3";
274        let elements = MarkdownParser::parse(text);
275
276        assert_eq!(elements.len(), 3);
277        assert!(matches!(elements[0], MarkdownElement::ListItem(_)));
278    }
279
280    #[test]
281    fn test_parse_inline_bold() {
282        let text = "This is **bold** text";
283        let elements = MarkdownParser::parse_inline(text);
284
285        assert!(elements
286            .iter()
287            .any(|e| matches!(e, MarkdownElement::Bold(_))));
288    }
289
290    #[test]
291    fn test_parse_inline_italic() {
292        let text = "This is *italic* text";
293        let elements = MarkdownParser::parse_inline(text);
294
295        assert!(elements
296            .iter()
297            .any(|e| matches!(e, MarkdownElement::Italic(_))));
298    }
299
300    #[test]
301    fn test_parse_inline_code() {
302        let text = "Use `let x = 5;` for variables";
303        let elements = MarkdownParser::parse_inline(text);
304
305        assert!(elements
306            .iter()
307            .any(|e| matches!(e, MarkdownElement::Code(_))));
308    }
309
310    #[test]
311    fn test_parse_link() {
312        let text = "Visit [example](https://example.com)";
313        let elements = MarkdownParser::parse_inline(text);
314
315        assert!(elements
316            .iter()
317            .any(|e| matches!(e, MarkdownElement::Link(_, _))));
318    }
319
320    #[test]
321    fn test_render_plain() {
322        let elements = vec![
323            MarkdownElement::Header(1, "Title".to_string()),
324            MarkdownElement::Text("Some text".to_string()),
325        ];
326
327        let output = MarkdownParser::render_plain(&elements);
328        assert!(output.contains("# Title"));
329        assert!(output.contains("Some text"));
330    }
331}