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