sparrow/tui/formatters/
markdown.rs1use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, TagEnd};
7use syntect::highlighting::ThemeSet;
8use syntect::parsing::SyntaxSet;
9use syntect::util::LinesWithEndings;
10
11pub struct MarkdownStyles {
13 pub h1: &'static str,
14 pub h2: &'static str,
15 pub h3: &'static str,
16 pub bold: &'static str,
17 pub italic: &'static str,
18 pub code_inline: &'static str,
19 pub code_block_lang: &'static str,
20 pub link_url: &'static str,
21 pub list_marker: &'static str,
22 pub blockquote: &'static str,
23 pub hr: &'static str,
24 pub text: &'static str,
25 pub reset: &'static str,
26}
27
28impl Default for MarkdownStyles {
29 fn default() -> Self {
30 Self {
31 h1: "\x1b[1;38;2;242;169;60m", h2: "\x1b[1;38;2;111;166;230m", h3: "\x1b[1;38;2;78;201;176m", bold: "\x1b[1m",
35 italic: "\x1b[3m",
36 code_inline: "\x1b[48;2;22;18;13;38;2;242;201;76m", code_block_lang: "\x1b[38;2;137;125;108m", link_url: "\x1b[38;2;111;166;230m", list_marker: "\x1b[38;2;242;169;60m", blockquote: "\x1b[38;2;137;125;108m", hr: "\x1b[38;2;92;83;70m", text: "", reset: "\x1b[0m",
44 }
45 }
46}
47
48const DEFAULT_SYNTAX_THEME: &str = "base16-ocean.dark";
50
51pub fn render_markdown(md: &str) -> String {
56 render_markdown_with_theme(md, DEFAULT_SYNTAX_THEME)
57}
58
59pub fn render_markdown_with_theme(md: &str, syntax_theme: &str) -> String {
61 let styles = MarkdownStyles::default();
62 let mut options = Options::empty();
63 options.insert(Options::ENABLE_STRIKETHROUGH);
64 options.insert(Options::ENABLE_TABLES);
65 options.insert(Options::ENABLE_FOOTNOTES);
66 options.insert(Options::ENABLE_TASKLISTS);
67
68 let parser = Parser::new_ext(md, options);
69
70 let ss = SyntaxSet::load_defaults_newlines();
71 let ts = ThemeSet::load_defaults();
72 let theme = ts
73 .themes
74 .get(syntax_theme)
75 .or_else(|| ts.themes.get(DEFAULT_SYNTAX_THEME))
76 .expect("built-in theme should exist");
77
78 let mut out = String::with_capacity(md.len() * 2);
79 let mut in_code_block = false;
80 let mut code_lang: Option<String> = None;
81 let mut current_link_url: Option<String> = None;
82 let mut code_buf = String::new();
83 let mut list_depth: usize = 0;
84 let mut ordered_index: Option<u64> = None;
85
86 for event in parser {
87 match event {
88 Event::Start(tag) => match tag {
89 Tag::Heading {
90 level,
91 id: _,
92 classes: _,
93 attrs: _,
94 } => {
95 let style = match level {
96 pulldown_cmark::HeadingLevel::H1 => styles.h1,
97 pulldown_cmark::HeadingLevel::H2 => styles.h2,
98 _ => styles.h3,
99 };
100 out.push_str(style);
101 let hashes: String = match level {
103 pulldown_cmark::HeadingLevel::H1 => "# ".into(),
104 pulldown_cmark::HeadingLevel::H2 => "## ".into(),
105 pulldown_cmark::HeadingLevel::H3 => "### ".into(),
106 pulldown_cmark::HeadingLevel::H4 => "#### ".into(),
107 pulldown_cmark::HeadingLevel::H5 => "##### ".into(),
108 pulldown_cmark::HeadingLevel::H6 => "###### ".into(),
109 };
110 out.push_str(&hashes);
111 }
112 Tag::CodeBlock(kind) => {
113 in_code_block = true;
114 code_buf.clear();
115 if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind {
116 let lang_str = lang.to_string();
117 if !lang_str.is_empty() {
118 code_lang = Some(lang_str);
119 } else {
120 code_lang = None;
121 }
122 } else {
123 code_lang = None;
124 }
125
126 out.push('\n');
128 if let Some(ref lang) = code_lang {
129 out.push_str(&format!(
130 "{style} ┌─ {lang} ─────────────────────────────{reset}\n",
131 style = styles.code_block_lang,
132 reset = styles.reset
133 ));
134 } else {
135 out.push_str(&format!(
136 "{style} ┌─ code ───────────────────────────────{reset}\n",
137 style = styles.code_block_lang,
138 reset = styles.reset
139 ));
140 }
141 }
142 Tag::List(order) => {
143 list_depth += 1;
144 if let Some(start) = order {
145 ordered_index = Some(start);
146 } else {
147 ordered_index = None;
148 }
149 }
150 Tag::Item => {
151 out.push_str(styles.list_marker);
152 let indent = " ".repeat(list_depth.saturating_sub(1));
153 out.push_str(&indent);
154 if let Some(idx) = ordered_index.as_mut() {
155 out.push_str(&format!("{idx}. "));
156 *idx += 1;
157 } else {
158 out.push_str("• ");
159 }
160 out.push_str(styles.reset);
161 }
162 Tag::BlockQuote(_) => {
163 out.push_str(styles.blockquote);
164 }
165 Tag::Strong => {
166 out.push_str(styles.bold);
167 }
168 Tag::Emphasis => {
169 out.push_str(styles.italic);
170 }
171 Tag::Strikethrough => {
172 out.push_str(styles.blockquote);
174 }
175 Tag::Link {
176 link_type: _,
177 dest_url: _,
178 title: _,
179 id: _,
180 } => {
181 }
183 Tag::Image {
184 link_type: _,
185 dest_url: _,
186 title: _,
187 id: _,
188 } => {
189 out.push_str(styles.blockquote);
190 out.push_str("[img: ");
191 }
192 _ => {}
193 },
194 Event::End(tag) => match tag {
195 TagEnd::Heading(_) => {
196 out.push_str(styles.reset);
197 out.push('\n');
198 }
200 TagEnd::CodeBlock => {
201 in_code_block = false;
202 let lang = code_lang.as_deref().unwrap_or("");
204 let highlighted = highlight_code_block(&code_buf, lang, theme, &ss);
205 for line in highlighted.lines() {
207 out.push_str(" │ ");
208 out.push_str(line);
209 out.push('\n');
210 }
211 out.push_str(&format!(
212 "{style} └──────────────────────────────────────────{reset}\n",
213 style = styles.code_block_lang,
214 reset = styles.reset
215 ));
216 code_buf.clear();
217 code_lang = None;
218 }
219 TagEnd::List(_) => {
220 list_depth = list_depth.saturating_sub(1);
221 ordered_index = None;
222 }
223 TagEnd::Item => {
224 out.push('\n');
225 }
226 TagEnd::BlockQuote(_) => {
227 out.push_str(styles.reset);
228 }
229 TagEnd::Strong => {
230 out.push_str(styles.reset);
231 }
232 TagEnd::Emphasis => {
233 out.push_str(styles.reset);
234 }
235 TagEnd::Strikethrough => {
236 out.push_str(styles.reset);
237 }
238 TagEnd::Link => {
239 out.push_str(styles.blockquote);
241 out.push(' ');
242 out.push_str(styles.link_url);
243 if let Some(ref url) = current_link_url {
244 out.push('(');
245 out.push_str(url);
246 out.push(')');
247 }
248 out.push_str(styles.reset);
249 current_link_url = None;
250 }
251 TagEnd::Image => {
252 out.push_str(styles.blockquote);
253 out.push(']');
254 out.push_str(styles.reset);
255 current_link_url = None;
256 }
257 _ => {}
258 },
259 Event::Text(text) | Event::Code(text) => {
260 if in_code_block {
261 code_buf.push_str(&text);
262 } else {
263 out.push_str(&text);
264 }
265 }
266 Event::Html(raw) => {
267 let stripped = strip_html_tags(&raw);
269 if !stripped.is_empty() {
270 out.push_str(&stripped);
271 }
272 }
273 Event::InlineHtml(raw) => {
274 out.push_str(styles.blockquote);
275 out.push_str(&raw);
276 out.push_str(styles.reset);
277 }
278 Event::InlineMath(raw) | Event::DisplayMath(raw) => {
279 out.push_str(styles.code_inline);
280 out.push_str(&raw);
281 out.push_str(styles.reset);
282 }
283 Event::FootnoteReference(name) => {
284 out.push_str(styles.link_url);
285 out.push_str(&format!("[^{}]", name));
286 out.push_str(styles.reset);
287 }
288 Event::SoftBreak => {
289 out.push(' ');
290 }
291 Event::HardBreak => {
292 out.push('\n');
293 }
294 Event::Rule => {
295 out.push('\n');
296 out.push_str(styles.hr);
297 out.push_str(&"─".repeat(60));
298 out.push_str(styles.reset);
299 out.push('\n');
300 }
301 Event::TaskListMarker(checked) => {
302 out.push_str(styles.list_marker);
303 if checked {
304 out.push_str("[x] ");
305 } else {
306 out.push_str("[ ] ");
307 }
308 out.push_str(styles.reset);
309 }
310 }
311 }
312
313 out
314}
315
316fn strip_html_tags(html: &str) -> String {
318 let mut out = String::new();
319 let mut in_tag = false;
320 for ch in html.chars() {
321 if ch == '<' {
322 in_tag = true;
323 } else if ch == '>' {
324 in_tag = false;
325 } else if !in_tag {
326 out.push(ch);
327 }
328 }
329 out
330}
331
332fn highlight_code_block(
334 code: &str,
335 language: &str,
336 theme: &syntect::highlighting::Theme,
337 ss: &SyntaxSet,
338) -> String {
339 use syntect::easy::HighlightLines;
340
341 let syntax = super::code::language_syntax(ss, language);
342
343 let mut h = HighlightLines::new(syntax, theme);
344 let mut out = String::with_capacity(code.len() * 2);
345
346 for line in LinesWithEndings::from(code) {
347 let Ok(ranges) = h.highlight_line(line, ss) else {
348 out.push_str(line);
349 continue;
350 };
351 let escaped = syntect::util::as_24_bit_terminal_escaped(&ranges[..], false);
352 out.push_str(&escaped);
353 }
354
355 out.trim_end_matches('\n').to_string()
356}