1use gpui::{
2 DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle,
3 UnderlineStyle, px,
4};
5use language::HighlightId;
6use std::{fmt::Display, ops::Range, path::PathBuf};
7use urlencoding;
8
9#[derive(Debug)]
10#[cfg_attr(test, derive(PartialEq))]
11pub enum ParsedMarkdownElement {
12 Heading(ParsedMarkdownHeading),
13 ListItem(ParsedMarkdownListItem),
14 Table(ParsedMarkdownTable),
15 BlockQuote(ParsedMarkdownBlockQuote),
16 CodeBlock(ParsedMarkdownCodeBlock),
17 /// A paragraph of text and other inline elements.
18 Paragraph(MarkdownParagraph),
19 HorizontalRule(Range<usize>),
20 Image(Image),
21}
22
23impl ParsedMarkdownElement {
24 pub fn source_range(&self) -> Option<Range<usize>> {
25 Some(match self {
26 Self::Heading(heading) => heading.source_range.clone(),
27 Self::ListItem(list_item) => list_item.source_range.clone(),
28 Self::Table(table) => table.source_range.clone(),
29 Self::BlockQuote(block_quote) => block_quote.source_range.clone(),
30 Self::CodeBlock(code_block) => code_block.source_range.clone(),
31 Self::Paragraph(text) => match text.get(0)? {
32 MarkdownParagraphChunk::Text(t) => t.source_range.clone(),
33 MarkdownParagraphChunk::Image(image) => image.source_range.clone(),
34 },
35 Self::HorizontalRule(range) => range.clone(),
36 Self::Image(image) => image.source_range.clone(),
37 })
38 }
39
40 pub fn is_list_item(&self) -> bool {
41 matches!(self, Self::ListItem(_))
42 }
43}
44
45pub type MarkdownParagraph = Vec<MarkdownParagraphChunk>;
46
47#[derive(Debug)]
48#[cfg_attr(test, derive(PartialEq))]
49pub enum MarkdownParagraphChunk {
50 Text(ParsedMarkdownText),
51 Image(Image),
52}
53
54#[derive(Debug)]
55#[cfg_attr(test, derive(PartialEq))]
56pub struct ParsedMarkdown {
57 pub children: Vec<ParsedMarkdownElement>,
58}
59
60#[derive(Debug)]
61#[cfg_attr(test, derive(PartialEq))]
62pub struct ParsedMarkdownListItem {
63 pub source_range: Range<usize>,
64 /// How many indentations deep this item is.
65 pub depth: u16,
66 pub item_type: ParsedMarkdownListItemType,
67 pub content: Vec<ParsedMarkdownElement>,
68 /// Whether we can expect nested list items inside of this items `content`.
69 pub nested: bool,
70}
71
72#[derive(Debug)]
73#[cfg_attr(test, derive(PartialEq))]
74pub enum ParsedMarkdownListItemType {
75 Ordered(u64),
76 Task(bool, Range<usize>),
77 Unordered,
78}
79
80#[derive(Debug)]
81#[cfg_attr(test, derive(PartialEq))]
82pub struct ParsedMarkdownCodeBlock {
83 pub source_range: Range<usize>,
84 pub language: Option<String>,
85 pub contents: SharedString,
86 pub highlights: Option<Vec<(Range<usize>, HighlightId)>>,
87}
88
89#[derive(Debug)]
90#[cfg_attr(test, derive(PartialEq))]
91pub struct ParsedMarkdownHeading {
92 pub source_range: Range<usize>,
93 pub level: HeadingLevel,
94 pub contents: MarkdownParagraph,
95}
96
97#[derive(Debug, PartialEq)]
98pub enum HeadingLevel {
99 H1,
100 H2,
101 H3,
102 H4,
103 H5,
104 H6,
105}
106
107#[derive(Debug)]
108pub struct ParsedMarkdownTable {
109 pub source_range: Range<usize>,
110 pub header: Vec<ParsedMarkdownTableRow>,
111 pub body: Vec<ParsedMarkdownTableRow>,
112 pub caption: Option<MarkdownParagraph>,
113}
114
115#[derive(Debug, Clone, Copy, Default)]
116#[cfg_attr(test, derive(PartialEq))]
117pub enum ParsedMarkdownTableAlignment {
118 #[default]
119 None,
120 Left,
121 Center,
122 Right,
123}
124
125#[derive(Debug)]
126#[cfg_attr(test, derive(PartialEq))]
127pub struct ParsedMarkdownTableColumn {
128 pub col_span: usize,
129 pub row_span: usize,
130 pub is_header: bool,
131 pub children: MarkdownParagraph,
132 pub alignment: ParsedMarkdownTableAlignment,
133}
134
135#[derive(Debug)]
136#[cfg_attr(test, derive(PartialEq))]
137pub struct ParsedMarkdownTableRow {
138 pub columns: Vec<ParsedMarkdownTableColumn>,
139}
140
141impl Default for ParsedMarkdownTableRow {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147impl ParsedMarkdownTableRow {
148 pub fn new() -> Self {
149 Self {
150 columns: Vec::new(),
151 }
152 }
153
154 pub fn with_columns(columns: Vec<ParsedMarkdownTableColumn>) -> Self {
155 Self { columns }
156 }
157}
158
159#[derive(Debug)]
160#[cfg_attr(test, derive(PartialEq))]
161pub struct ParsedMarkdownBlockQuote {
162 pub source_range: Range<usize>,
163 pub children: Vec<ParsedMarkdownElement>,
164}
165
166#[derive(Debug, Clone)]
167pub struct ParsedMarkdownText {
168 /// Where the text is located in the source Markdown document.
169 pub source_range: Range<usize>,
170 /// The text content stripped of any formatting symbols.
171 pub contents: SharedString,
172 /// The list of highlights contained in the Markdown document.
173 pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
174 /// The regions of the various ranges in the Markdown document.
175 pub region_ranges: Vec<Range<usize>>,
176 /// The regions of the Markdown document.
177 pub regions: Vec<ParsedRegion>,
178}
179
180/// A run of highlighted Markdown text.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum MarkdownHighlight {
183 /// A styled Markdown highlight.
184 Style(MarkdownHighlightStyle),
185 /// A highlighted code block.
186 Code(HighlightId),
187}
188
189impl MarkdownHighlight {
190 /// Converts this [`MarkdownHighlight`] to a [`HighlightStyle`].
191 pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option<HighlightStyle> {
192 match self {
193 MarkdownHighlight::Style(style) => {
194 let mut highlight = HighlightStyle::default();
195
196 if style.italic {
197 highlight.font_style = Some(FontStyle::Italic);
198 }
199
200 if style.underline {
201 highlight.underline = Some(UnderlineStyle {
202 thickness: px(1.),
203 ..Default::default()
204 });
205 }
206
207 if style.strikethrough {
208 highlight.strikethrough = Some(StrikethroughStyle {
209 thickness: px(1.),
210 ..Default::default()
211 });
212 }
213
214 if style.weight != FontWeight::default() {
215 highlight.font_weight = Some(style.weight);
216 }
217
218 if style.link {
219 highlight.underline = Some(UnderlineStyle {
220 thickness: px(1.),
221 ..Default::default()
222 });
223 }
224
225 if style.oblique {
226 highlight.font_style = Some(FontStyle::Oblique)
227 }
228
229 Some(highlight)
230 }
231
232 MarkdownHighlight::Code(id) => id.style(theme),
233 }
234 }
235}
236
237/// The style for a Markdown highlight.
238#[derive(Debug, Clone, Default, PartialEq, Eq)]
239pub struct MarkdownHighlightStyle {
240 /// Whether the text should be italicized.
241 pub italic: bool,
242 /// Whether the text should be underlined.
243 pub underline: bool,
244 /// Whether the text should be struck through.
245 pub strikethrough: bool,
246 /// The weight of the text.
247 pub weight: FontWeight,
248 /// Whether the text should be stylized as link.
249 pub link: bool,
250 // Whether the text should be obliqued.
251 pub oblique: bool,
252}
253
254/// A parsed region in a Markdown document.
255#[derive(Debug, Clone)]
256#[cfg_attr(test, derive(PartialEq))]
257pub struct ParsedRegion {
258 /// Whether the region is a code block.
259 pub code: bool,
260 /// The link contained in this region, if it has one.
261 pub link: Option<Link>,
262}
263
264/// A Markdown link.
265#[derive(Debug, Clone)]
266#[cfg_attr(test, derive(PartialEq))]
267pub enum Link {
268 /// A link to a webpage.
269 Web {
270 /// The URL of the webpage.
271 url: String,
272 },
273 /// A link to a path on the filesystem.
274 Path {
275 /// The path as provided in the Markdown document.
276 display_path: PathBuf,
277 /// The absolute path to the item.
278 path: PathBuf,
279 },
280}
281
282impl Link {
283 pub fn identify(file_location_directory: Option<PathBuf>, text: String) -> Option<Link> {
284 if text.starts_with("http") {
285 return Some(Link::Web { url: text });
286 }
287
288 // URL decode the text to handle spaces and other special characters
289 let decoded_text = urlencoding::decode(&text)
290 .map(|s| s.into_owned())
291 .unwrap_or(text);
292
293 let path = PathBuf::from(&decoded_text);
294 if path.is_absolute() && path.exists() {
295 return Some(Link::Path {
296 display_path: path.clone(),
297 path,
298 });
299 }
300
301 if let Some(file_location_directory) = file_location_directory {
302 let display_path = path;
303 let path = file_location_directory.join(decoded_text);
304 if path.exists() {
305 return Some(Link::Path { display_path, path });
306 }
307 }
308
309 None
310 }
311}
312
313impl Display for Link {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 match self {
316 Link::Web { url } => write!(f, "{}", url),
317 Link::Path { display_path, .. } => write!(f, "{}", display_path.display()),
318 }
319 }
320}
321
322/// A Markdown Image
323#[derive(Debug, Clone)]
324#[cfg_attr(test, derive(PartialEq))]
325pub struct Image {
326 pub link: Link,
327 pub source_range: Range<usize>,
328 pub alt_text: Option<SharedString>,
329 pub width: Option<DefiniteLength>,
330 pub height: Option<DefiniteLength>,
331}
332
333impl Image {
334 pub fn identify(
335 text: String,
336 source_range: Range<usize>,
337 file_location_directory: Option<PathBuf>,
338 ) -> Option<Self> {
339 let link = Link::identify(file_location_directory, text)?;
340 Some(Self {
341 source_range,
342 link,
343 alt_text: None,
344 width: None,
345 height: None,
346 })
347 }
348
349 pub fn set_alt_text(&mut self, alt_text: SharedString) {
350 self.alt_text = Some(alt_text);
351 }
352
353 pub fn set_width(&mut self, width: DefiniteLength) {
354 self.width = Some(width);
355 }
356
357 pub fn set_height(&mut self, height: DefiniteLength) {
358 self.height = Some(height);
359 }
360}