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