ricecoder_tui/
markdown.rs1#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum MarkdownElement {
6 Text(String),
8 Header(u8, String),
10 Bold(String),
12 Italic(String),
14 Code(String),
16 CodeBlock(Option<String>, String),
18 ListItem(String),
20 Link(String, String),
22}
23
24pub struct MarkdownParser;
26
27impl MarkdownParser {
28 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 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 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 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 let parsed = Self::parse_inline(line);
76 elements.extend(parsed);
77 i += 1;
78 }
79
80 elements
81 }
82
83 #[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 if chars.peek() == Some(&ch) {
100 chars.next(); 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 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 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}