Skip to main content

rustyclaw_tui/
markdown.rs

1//! Simple markdown to styled text conversion for TUI rendering.
2//!
3//! Parses basic markdown and returns styled segments that can be rendered
4//! with iocraft Text elements.
5
6#[allow(unused_imports)]
7use iocraft::prelude::*;
8
9/// A styled text segment.
10#[derive(Debug, Clone)]
11pub struct StyledSegment {
12    pub text: String,
13    pub bold: bool,
14    pub italic: bool,
15    pub code: bool,
16    pub header_level: u8, // 0 = not a header, 1-6 = h1-h6
17}
18
19impl StyledSegment {
20    pub fn plain(text: impl Into<String>) -> Self {
21        Self {
22            text: text.into(),
23            bold: false,
24            italic: false,
25            code: false,
26            header_level: 0,
27        }
28    }
29
30    pub fn bold(text: impl Into<String>) -> Self {
31        Self {
32            text: text.into(),
33            bold: true,
34            italic: false,
35            code: false,
36            header_level: 0,
37        }
38    }
39
40    pub fn code(text: impl Into<String>) -> Self {
41        Self {
42            text: text.into(),
43            bold: false,
44            italic: false,
45            code: true,
46            header_level: 0,
47        }
48    }
49
50    pub fn header(text: impl Into<String>, level: u8) -> Self {
51        Self {
52            text: text.into(),
53            bold: true,
54            italic: false,
55            code: false,
56            header_level: level,
57        }
58    }
59}
60
61/// Parse markdown text into styled segments.
62///
63/// Supports:
64/// - **bold** and __bold__
65/// - *italic* and _italic_
66/// - `inline code`
67/// - # Headers (at line start)
68/// - Lists (-, *, numbered) — rendered with bullet/number
69///
70/// Does NOT support (rendered as plain text):
71/// - Code blocks (```) — kept as-is with backticks stripped
72/// - Links, images
73/// - Tables
74pub fn parse_markdown(input: &str) -> Vec<StyledSegment> {
75    let mut segments = Vec::new();
76
77    for line in input.lines() {
78        // Check for headers
79        if let Some(header) = parse_header(line) {
80            segments.push(header);
81            segments.push(StyledSegment::plain("\n"));
82            continue;
83        }
84
85        // Parse inline formatting
86        parse_inline(line, &mut segments);
87        segments.push(StyledSegment::plain("\n"));
88    }
89
90    // Remove trailing newline
91    if let Some(last) = segments.last() {
92        if last.text == "\n" {
93            segments.pop();
94        }
95    }
96
97    segments
98}
99
100/// Parse a header line (# Header).
101fn parse_header(line: &str) -> Option<StyledSegment> {
102    let trimmed = line.trim_start();
103    let hashes = trimmed.chars().take_while(|&c| c == '#').count();
104
105    if hashes > 0 && hashes <= 6 {
106        let rest = trimmed[hashes..].trim_start();
107        if !rest.is_empty() || hashes == trimmed.len() {
108            return Some(StyledSegment::header(rest, hashes as u8));
109        }
110    }
111
112    None
113}
114
115/// Parse inline formatting (**bold**, *italic*, `code`).
116fn parse_inline(text: &str, segments: &mut Vec<StyledSegment>) {
117    let mut chars = text.chars().peekable();
118    let mut current = String::new();
119
120    while let Some(c) = chars.next() {
121        match c {
122            // Bold: ** or __
123            '*' | '_' if chars.peek() == Some(&c) => {
124                // Flush current
125                if !current.is_empty() {
126                    segments.push(StyledSegment::plain(std::mem::take(&mut current)));
127                }
128
129                chars.next(); // consume second marker
130                let marker = c;
131
132                // Collect until closing **
133                let mut bold_text = String::new();
134                while let Some(bc) = chars.next() {
135                    if bc == marker && chars.peek() == Some(&marker) {
136                        chars.next(); // consume second marker
137                        break;
138                    }
139                    bold_text.push(bc);
140                }
141
142                if !bold_text.is_empty() {
143                    segments.push(StyledSegment::bold(bold_text));
144                }
145            }
146
147            // Inline code: `code`
148            '`' => {
149                // Flush current
150                if !current.is_empty() {
151                    segments.push(StyledSegment::plain(std::mem::take(&mut current)));
152                }
153
154                // Check for code block (```)
155                if chars.peek() == Some(&'`') {
156                    chars.next();
157                    if chars.peek() == Some(&'`') {
158                        chars.next();
159                        // Code block — collect until closing ```
160                        let mut code_text = String::new();
161                        let mut backtick_count = 0;
162                        for cc in chars.by_ref() {
163                            if cc == '`' {
164                                backtick_count += 1;
165                                if backtick_count == 3 {
166                                    break;
167                                }
168                            } else {
169                                // Add any accumulated backticks that weren't closing
170                                for _ in 0..backtick_count {
171                                    code_text.push('`');
172                                }
173                                backtick_count = 0;
174                                code_text.push(cc);
175                            }
176                        }
177                        if !code_text.is_empty() {
178                            segments.push(StyledSegment::code(code_text));
179                        }
180                        continue;
181                    }
182                }
183
184                // Single backtick — inline code
185                let mut code_text = String::new();
186                for cc in chars.by_ref() {
187                    if cc == '`' {
188                        break;
189                    }
190                    code_text.push(cc);
191                }
192
193                if !code_text.is_empty() {
194                    segments.push(StyledSegment::code(code_text));
195                }
196            }
197
198            // Italic: single * or _ (not followed by same char)
199            '*' | '_' => {
200                // Flush current
201                if !current.is_empty() {
202                    segments.push(StyledSegment::plain(std::mem::take(&mut current)));
203                }
204
205                let marker = c;
206                let mut italic_text = String::new();
207
208                for ic in chars.by_ref() {
209                    if ic == marker {
210                        break;
211                    }
212                    italic_text.push(ic);
213                }
214
215                // For now, render italic as plain (most terminals don't support italic)
216                if !italic_text.is_empty() {
217                    // Could add italic: true to StyledSegment if terminal supports it
218                    segments.push(StyledSegment::plain(italic_text));
219                }
220            }
221
222            _ => {
223                current.push(c);
224            }
225        }
226    }
227
228    // Flush remaining
229    if !current.is_empty() {
230        segments.push(StyledSegment::plain(current));
231    }
232}
233
234/// Render markdown as a single string with ANSI escape codes.
235///
236/// This is a fallback for when we can't use multiple Text elements.
237pub fn render_ansi(input: &str) -> String {
238    let segments = parse_markdown(input);
239    let mut output = String::new();
240
241    for seg in segments {
242        if seg.bold || seg.header_level > 0 {
243            output.push_str("\x1b[1m"); // Bold
244        }
245        if seg.code {
246            output.push_str("\x1b[36m"); // Cyan for code
247        }
248
249        output.push_str(&seg.text);
250
251        if seg.bold || seg.header_level > 0 || seg.code {
252            output.push_str("\x1b[0m"); // Reset
253        }
254    }
255
256    output
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_plain_text() {
265        let segments = parse_markdown("Hello world");
266        assert_eq!(segments.len(), 1);
267        assert_eq!(segments[0].text, "Hello world");
268        assert!(!segments[0].bold);
269    }
270
271    #[test]
272    fn test_bold() {
273        let segments = parse_markdown("Hello **bold** world");
274        assert_eq!(segments.len(), 3);
275        assert_eq!(segments[0].text, "Hello ");
276        assert_eq!(segments[1].text, "bold");
277        assert!(segments[1].bold);
278        assert_eq!(segments[2].text, " world");
279    }
280
281    #[test]
282    fn test_inline_code() {
283        let segments = parse_markdown("Use `cargo build` here");
284        assert_eq!(segments.len(), 3);
285        assert_eq!(segments[1].text, "cargo build");
286        assert!(segments[1].code);
287    }
288
289    #[test]
290    fn test_header() {
291        let segments = parse_markdown("# Hello\nWorld");
292        assert_eq!(segments[0].header_level, 1);
293        assert_eq!(segments[0].text, "Hello");
294    }
295
296    #[test]
297    fn test_ansi_render() {
298        let output = render_ansi("Hello **bold** and `code`");
299        assert!(output.contains("\x1b[1m")); // Bold
300        assert!(output.contains("\x1b[36m")); // Cyan
301        assert!(output.contains("\x1b[0m")); // Reset
302    }
303}