markdown.rs

  1use std::sync::Arc;
  2use std::{ops::Range, path::PathBuf};
  3
  4use crate::{HighlightId, Language, LanguageRegistry};
  5use gpui::fonts::{self, HighlightStyle, Weight};
  6use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
  7
  8#[derive(Debug, Clone)]
  9pub struct ParsedMarkdown {
 10    pub text: String,
 11    pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
 12    pub region_ranges: Vec<Range<usize>>,
 13    pub regions: Vec<ParsedRegion>,
 14}
 15
 16#[derive(Debug, Clone, PartialEq, Eq)]
 17pub enum MarkdownHighlight {
 18    Style(MarkdownHighlightStyle),
 19    Code(HighlightId),
 20}
 21
 22impl MarkdownHighlight {
 23    pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option<HighlightStyle> {
 24        match self {
 25            MarkdownHighlight::Style(style) => {
 26                let mut highlight = HighlightStyle::default();
 27
 28                if style.italic {
 29                    highlight.italic = Some(true);
 30                }
 31
 32                if style.underline {
 33                    highlight.underline = Some(fonts::Underline {
 34                        thickness: 1.0.into(),
 35                        ..Default::default()
 36                    });
 37                }
 38
 39                if style.weight != fonts::Weight::default() {
 40                    highlight.weight = Some(style.weight);
 41                }
 42
 43                Some(highlight)
 44            }
 45
 46            MarkdownHighlight::Code(id) => id.style(theme),
 47        }
 48    }
 49}
 50
 51#[derive(Debug, Clone, Default, PartialEq, Eq)]
 52pub struct MarkdownHighlightStyle {
 53    pub italic: bool,
 54    pub underline: bool,
 55    pub weight: Weight,
 56}
 57
 58#[derive(Debug, Clone)]
 59pub struct ParsedRegion {
 60    pub code: bool,
 61    pub link: Option<Link>,
 62}
 63
 64#[derive(Debug, Clone)]
 65pub enum Link {
 66    Web { url: String },
 67    Path { path: PathBuf },
 68}
 69
 70impl Link {
 71    fn identify(text: String) -> Option<Link> {
 72        if text.starts_with("http") {
 73            return Some(Link::Web { url: text });
 74        }
 75
 76        let path = PathBuf::from(text);
 77        if path.is_absolute() {
 78            return Some(Link::Path { path });
 79        }
 80
 81        None
 82    }
 83}
 84
 85pub async fn parse_markdown(
 86    markdown: &str,
 87    language_registry: &Arc<LanguageRegistry>,
 88    language: Option<Arc<Language>>,
 89) -> ParsedMarkdown {
 90    let mut text = String::new();
 91    let mut highlights = Vec::new();
 92    let mut region_ranges = Vec::new();
 93    let mut regions = Vec::new();
 94
 95    parse_markdown_block(
 96        markdown,
 97        language_registry,
 98        language,
 99        &mut text,
100        &mut highlights,
101        &mut region_ranges,
102        &mut regions,
103    )
104    .await;
105
106    ParsedMarkdown {
107        text,
108        highlights,
109        region_ranges,
110        regions,
111    }
112}
113
114pub async fn parse_markdown_block(
115    markdown: &str,
116    language_registry: &Arc<LanguageRegistry>,
117    language: Option<Arc<Language>>,
118    text: &mut String,
119    highlights: &mut Vec<(Range<usize>, MarkdownHighlight)>,
120    region_ranges: &mut Vec<Range<usize>>,
121    regions: &mut Vec<ParsedRegion>,
122) {
123    let mut bold_depth = 0;
124    let mut italic_depth = 0;
125    let mut link_url = None;
126    let mut current_language = None;
127    let mut list_stack = Vec::new();
128
129    for event in Parser::new_ext(&markdown, Options::all()) {
130        let prev_len = text.len();
131        match event {
132            Event::Text(t) => {
133                if let Some(language) = &current_language {
134                    highlight_code(text, highlights, t.as_ref(), language);
135                } else {
136                    text.push_str(t.as_ref());
137
138                    let mut style = MarkdownHighlightStyle::default();
139
140                    if bold_depth > 0 {
141                        style.weight = Weight::BOLD;
142                    }
143
144                    if italic_depth > 0 {
145                        style.italic = true;
146                    }
147
148                    if let Some(link) = link_url.clone().and_then(|u| Link::identify(u)) {
149                        region_ranges.push(prev_len..text.len());
150                        regions.push(ParsedRegion {
151                            code: false,
152                            link: Some(link),
153                        });
154                        style.underline = true;
155                    }
156
157                    if style != MarkdownHighlightStyle::default() {
158                        let mut new_highlight = true;
159                        if let Some((last_range, MarkdownHighlight::Style(last_style))) =
160                            highlights.last_mut()
161                        {
162                            if last_range.end == prev_len && last_style == &style {
163                                last_range.end = text.len();
164                                new_highlight = false;
165                            }
166                        }
167                        if new_highlight {
168                            let range = prev_len..text.len();
169                            highlights.push((range, MarkdownHighlight::Style(style)));
170                        }
171                    }
172                }
173            }
174
175            Event::Code(t) => {
176                text.push_str(t.as_ref());
177                region_ranges.push(prev_len..text.len());
178
179                let link = link_url.clone().and_then(|u| Link::identify(u));
180                if link.is_some() {
181                    highlights.push((
182                        prev_len..text.len(),
183                        MarkdownHighlight::Style(MarkdownHighlightStyle {
184                            underline: true,
185                            ..Default::default()
186                        }),
187                    ));
188                }
189                regions.push(ParsedRegion { code: true, link });
190            }
191
192            Event::Start(tag) => match tag {
193                Tag::Paragraph => new_paragraph(text, &mut list_stack),
194
195                Tag::Heading(_, _, _) => {
196                    new_paragraph(text, &mut list_stack);
197                    bold_depth += 1;
198                }
199
200                Tag::CodeBlock(kind) => {
201                    new_paragraph(text, &mut list_stack);
202                    current_language = if let CodeBlockKind::Fenced(language) = kind {
203                        language_registry
204                            .language_for_name(language.as_ref())
205                            .await
206                            .ok()
207                    } else {
208                        language.clone()
209                    }
210                }
211
212                Tag::Emphasis => italic_depth += 1,
213
214                Tag::Strong => bold_depth += 1,
215
216                Tag::Link(_, url, _) => link_url = Some(url.to_string()),
217
218                Tag::List(number) => {
219                    list_stack.push((number, false));
220                }
221
222                Tag::Item => {
223                    let len = list_stack.len();
224                    if let Some((list_number, has_content)) = list_stack.last_mut() {
225                        *has_content = false;
226                        if !text.is_empty() && !text.ends_with('\n') {
227                            text.push('\n');
228                        }
229                        for _ in 0..len - 1 {
230                            text.push_str("  ");
231                        }
232                        if let Some(number) = list_number {
233                            text.push_str(&format!("{}. ", number));
234                            *number += 1;
235                            *has_content = false;
236                        } else {
237                            text.push_str("- ");
238                        }
239                    }
240                }
241
242                _ => {}
243            },
244
245            Event::End(tag) => match tag {
246                Tag::Heading(_, _, _) => bold_depth -= 1,
247                Tag::CodeBlock(_) => current_language = None,
248                Tag::Emphasis => italic_depth -= 1,
249                Tag::Strong => bold_depth -= 1,
250                Tag::Link(_, _, _) => link_url = None,
251                Tag::List(_) => drop(list_stack.pop()),
252                _ => {}
253            },
254
255            Event::HardBreak => text.push('\n'),
256
257            Event::SoftBreak => text.push(' '),
258
259            _ => {}
260        }
261    }
262}
263
264pub fn highlight_code(
265    text: &mut String,
266    highlights: &mut Vec<(Range<usize>, MarkdownHighlight)>,
267    content: &str,
268    language: &Arc<Language>,
269) {
270    let prev_len = text.len();
271    text.push_str(content);
272    for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
273        let highlight = MarkdownHighlight::Code(highlight_id);
274        highlights.push((prev_len + range.start..prev_len + range.end, highlight));
275    }
276}
277
278pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
279    let mut is_subsequent_paragraph_of_list = false;
280    if let Some((_, has_content)) = list_stack.last_mut() {
281        if *has_content {
282            is_subsequent_paragraph_of_list = true;
283        } else {
284            *has_content = true;
285            return;
286        }
287    }
288
289    if !text.is_empty() {
290        if !text.ends_with('\n') {
291            text.push('\n');
292        }
293        text.push('\n');
294    }
295    for _ in 0..list_stack.len().saturating_sub(1) {
296        text.push_str("  ");
297    }
298    if is_subsequent_paragraph_of_list {
299        text.push_str("  ");
300    }
301}