rustyclaw_tui/
markdown.rs1#[allow(unused_imports)]
7use iocraft::prelude::*;
8
9#[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, }
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
61pub fn parse_markdown(input: &str) -> Vec<StyledSegment> {
75 let mut segments = Vec::new();
76
77 for line in input.lines() {
78 if let Some(header) = parse_header(line) {
80 segments.push(header);
81 segments.push(StyledSegment::plain("\n"));
82 continue;
83 }
84
85 parse_inline(line, &mut segments);
87 segments.push(StyledSegment::plain("\n"));
88 }
89
90 if let Some(last) = segments.last() {
92 if last.text == "\n" {
93 segments.pop();
94 }
95 }
96
97 segments
98}
99
100fn 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
115fn 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 '*' | '_' if chars.peek() == Some(&c) => {
124 if !current.is_empty() {
126 segments.push(StyledSegment::plain(std::mem::take(&mut current)));
127 }
128
129 chars.next(); let marker = c;
131
132 let mut bold_text = String::new();
134 while let Some(bc) = chars.next() {
135 if bc == marker && chars.peek() == Some(&marker) {
136 chars.next(); 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 '`' => {
149 if !current.is_empty() {
151 segments.push(StyledSegment::plain(std::mem::take(&mut current)));
152 }
153
154 if chars.peek() == Some(&'`') {
156 chars.next();
157 if chars.peek() == Some(&'`') {
158 chars.next();
159 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 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 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 '*' | '_' => {
200 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 if !italic_text.is_empty() {
217 segments.push(StyledSegment::plain(italic_text));
219 }
220 }
221
222 _ => {
223 current.push(c);
224 }
225 }
226 }
227
228 if !current.is_empty() {
230 segments.push(StyledSegment::plain(current));
231 }
232}
233
234pub 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"); }
245 if seg.code {
246 output.push_str("\x1b[36m"); }
248
249 output.push_str(&seg.text);
250
251 if seg.bold || seg.header_level > 0 || seg.code {
252 output.push_str("\x1b[0m"); }
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")); assert!(output.contains("\x1b[36m")); assert!(output.contains("\x1b[0m")); }
303}