graphrag_cli/ui/
markdown.rs1use ratatui::{
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9};
10
11pub fn parse_markdown(text: &str) -> Vec<Line<'static>> {
13 let mut lines = Vec::new();
14 let mut in_code_block = false;
15
16 for raw_line in text.lines() {
17 if raw_line.trim_start().starts_with("```") {
19 in_code_block = !in_code_block;
20 continue;
21 }
22
23 if in_code_block {
24 lines.push(Line::from(vec![Span::styled(
25 raw_line.to_owned(),
26 Style::default().fg(Color::Yellow),
27 )]));
28 continue;
29 }
30
31 if raw_line.trim().is_empty() {
32 lines.push(Line::from(""));
33 continue;
34 }
35
36 if raw_line.trim().len() >= 3
38 && raw_line.trim().chars().all(|c| c == '-' || c == '=' || c == '━')
39 {
40 lines.push(Line::from(vec![Span::styled(
41 "━".repeat(50),
42 Style::default().fg(Color::DarkGray),
43 )]));
44 continue;
45 }
46
47 let header_level = raw_line.chars().take_while(|&c| c == '#').count();
49 if header_level > 0
50 && header_level <= 3
51 && raw_line.as_bytes().get(header_level) == Some(&b' ')
52 {
53 let content = raw_line[header_level + 1..].to_owned();
54 let style = header_style(header_level);
55 let prefix = match header_level {
56 1 => "▌ ",
57 2 => " │ ",
58 _ => " · ",
59 };
60 lines.push(Line::from(vec![
61 Span::styled(prefix.to_owned(), style),
62 Span::styled(content, style),
63 ]));
64 continue;
65 }
66
67 if raw_line.starts_with("- ") || raw_line.starts_with("* ") {
69 let content = &raw_line[2..];
70 let mut spans =
71 vec![Span::styled(" • ".to_owned(), Style::default().fg(Color::Cyan))];
72 spans.extend(parse_inline(content));
73 lines.push(Line::from(spans));
74 continue;
75 }
76
77 if (raw_line.starts_with(" - ") || raw_line.starts_with(" * "))
79 && raw_line.len() > 4
80 {
81 let content = &raw_line[4..];
82 let mut spans = vec![Span::styled(
83 " ◦ ".to_owned(),
84 Style::default().fg(Color::DarkGray),
85 )];
86 spans.extend(parse_inline(content));
87 lines.push(Line::from(spans));
88 continue;
89 }
90
91 if raw_line.starts_with("> ") {
93 let content = &raw_line[2..];
94 let style = Style::default()
95 .fg(Color::DarkGray)
96 .add_modifier(Modifier::ITALIC);
97 lines.push(Line::from(vec![
98 Span::styled(
99 " │ ".to_owned(),
100 Style::default().fg(Color::DarkGray),
101 ),
102 Span::styled(content.to_owned(), style),
103 ]));
104 continue;
105 }
106
107 lines.push(Line::from(parse_inline(raw_line)));
109 }
110
111 lines
112}
113
114fn header_style(level: usize) -> Style {
115 match level {
116 1 => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
117 2 => Style::default()
118 .fg(Color::LightBlue)
119 .add_modifier(Modifier::BOLD),
120 _ => Style::default()
121 .fg(Color::Blue)
122 .add_modifier(Modifier::BOLD),
123 }
124}
125
126pub fn parse_inline(text: &str) -> Vec<Span<'static>> {
129 let mut result: Vec<Span<'static>> = Vec::new();
130 let mut current = String::new();
131 let chars: Vec<char> = text.chars().collect();
132 let n = chars.len();
133 let mut i = 0;
134
135 while i < n {
136 if i + 1 < n && chars[i] == '*' && chars[i + 1] == '*' {
138 if !current.is_empty() {
139 result.push(Span::raw(current.clone()));
140 current.clear();
141 }
142 let start = i + 2;
143 let mut end = None;
144 let mut j = start;
145 while j + 1 < n {
146 if chars[j] == '*' && chars[j + 1] == '*' {
147 end = Some(j);
148 break;
149 }
150 j += 1;
151 }
152 if let Some(e) = end {
153 let bold_text: String = chars[start..e].iter().collect();
154 result.push(Span::styled(
155 bold_text,
156 Style::default().add_modifier(Modifier::BOLD),
157 ));
158 i = e + 2;
159 } else {
160 current.push('*');
161 current.push('*');
162 i += 2;
163 }
164 continue;
165 }
166
167 if chars[i] == '*' && (i + 1 >= n || chars[i + 1] != '*') {
169 if !current.is_empty() {
170 result.push(Span::raw(current.clone()));
171 current.clear();
172 }
173 let start = i + 1;
174 let end = chars[start..].iter().position(|&c| c == '*');
175 if let Some(e) = end {
176 let italic_text: String = chars[start..start + e].iter().collect();
177 result.push(Span::styled(
178 italic_text,
179 Style::default().add_modifier(Modifier::ITALIC),
180 ));
181 i = start + e + 1;
182 } else {
183 current.push('*');
184 i += 1;
185 }
186 continue;
187 }
188
189 if chars[i] == '`' {
191 if !current.is_empty() {
192 result.push(Span::raw(current.clone()));
193 current.clear();
194 }
195 let start = i + 1;
196 let end = chars[start..].iter().position(|&c| c == '`');
197 if let Some(e) = end {
198 let code_text: String = chars[start..start + e].iter().collect();
199 result.push(Span::styled(
200 code_text,
201 Style::default().fg(Color::Yellow).bg(Color::DarkGray),
202 ));
203 i = start + e + 1;
204 } else {
205 current.push('`');
206 i += 1;
207 }
208 continue;
209 }
210
211 current.push(chars[i]);
212 i += 1;
213 }
214
215 if !current.is_empty() {
216 result.push(Span::raw(current));
217 }
218
219 if result.is_empty() {
220 result.push(Span::raw(String::new()));
221 }
222
223 result
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn test_parse_header() {
232 let lines = parse_markdown("# Hello World");
233 assert_eq!(lines.len(), 1);
234 }
235
236 #[test]
237 fn test_parse_bullet() {
238 let lines = parse_markdown("- item one\n- item two");
239 assert_eq!(lines.len(), 2);
240 }
241
242 #[test]
243 fn test_parse_code_block() {
244 let lines = parse_markdown("```\ncode line\n```");
245 assert_eq!(lines.len(), 1);
246 }
247
248 #[test]
249 fn test_parse_inline_bold() {
250 let spans = parse_inline("Hello **world**!");
251 assert_eq!(spans.len(), 3);
252 }
253
254 #[test]
255 fn test_parse_inline_code() {
256 let spans = parse_inline("Use `cargo build` now");
257 assert_eq!(spans.len(), 3);
258 }
259}