1use crate::{
2 markdown_elements::*,
3 markdown_minifier::{Minifier, MinifierOptions},
4};
5use async_recursion::async_recursion;
6use collections::FxHashMap;
7use gpui::{DefiniteLength, FontWeight, px, relative};
8use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink};
9use language::LanguageRegistry;
10use markdown::parser::PARSE_OPTIONS;
11use markup5ever_rcdom::RcDom;
12use pulldown_cmark::{Alignment, Event, Parser, Tag, TagEnd};
13use std::{
14 cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec,
15};
16use ui::SharedString;
17
18pub async fn parse_markdown(
19 markdown_input: &str,
20 file_location_directory: Option<PathBuf>,
21 language_registry: Option<Arc<LanguageRegistry>>,
22) -> ParsedMarkdown {
23 let parser = Parser::new_ext(markdown_input, PARSE_OPTIONS);
24 let parser = MarkdownParser::new(
25 parser.into_offset_iter().collect(),
26 file_location_directory,
27 language_registry,
28 );
29 let renderer = parser.parse_document().await;
30 ParsedMarkdown {
31 children: renderer.parsed,
32 }
33}
34
35fn cleanup_html(source: &str) -> Vec<u8> {
36 let mut writer = std::io::Cursor::new(Vec::new());
37 let mut reader = std::io::Cursor::new(source);
38 let mut minify = Minifier::new(
39 &mut writer,
40 MinifierOptions {
41 omit_doctype: true,
42 collapse_whitespace: true,
43 ..Default::default()
44 },
45 );
46 if let Ok(()) = minify.minify(&mut reader) {
47 writer.into_inner()
48 } else {
49 source.bytes().collect()
50 }
51}
52
53struct MarkdownParser<'a> {
54 tokens: Vec<(Event<'a>, Range<usize>)>,
55 /// The current index in the tokens array
56 cursor: usize,
57 /// The blocks that we have successfully parsed so far
58 parsed: Vec<ParsedMarkdownElement>,
59 file_location_directory: Option<PathBuf>,
60 language_registry: Option<Arc<LanguageRegistry>>,
61}
62
63#[derive(Debug)]
64struct ParseHtmlNodeContext {
65 list_item_depth: u16,
66}
67
68impl Default for ParseHtmlNodeContext {
69 fn default() -> Self {
70 Self { list_item_depth: 1 }
71 }
72}
73
74struct MarkdownListItem {
75 content: Vec<ParsedMarkdownElement>,
76 item_type: ParsedMarkdownListItemType,
77}
78
79impl Default for MarkdownListItem {
80 fn default() -> Self {
81 Self {
82 content: Vec::new(),
83 item_type: ParsedMarkdownListItemType::Unordered,
84 }
85 }
86}
87
88impl<'a> MarkdownParser<'a> {
89 fn new(
90 tokens: Vec<(Event<'a>, Range<usize>)>,
91 file_location_directory: Option<PathBuf>,
92 language_registry: Option<Arc<LanguageRegistry>>,
93 ) -> Self {
94 Self {
95 tokens,
96 file_location_directory,
97 language_registry,
98 cursor: 0,
99 parsed: vec![],
100 }
101 }
102
103 fn eof(&self) -> bool {
104 if self.tokens.is_empty() {
105 return true;
106 }
107 self.cursor >= self.tokens.len() - 1
108 }
109
110 fn peek(&self, steps: usize) -> Option<&(Event<'_>, Range<usize>)> {
111 if self.eof() || (steps + self.cursor) >= self.tokens.len() {
112 return self.tokens.last();
113 }
114 self.tokens.get(self.cursor + steps)
115 }
116
117 fn previous(&self) -> Option<&(Event<'_>, Range<usize>)> {
118 if self.cursor == 0 || self.cursor > self.tokens.len() {
119 return None;
120 }
121 self.tokens.get(self.cursor - 1)
122 }
123
124 fn current(&self) -> Option<&(Event<'_>, Range<usize>)> {
125 self.peek(0)
126 }
127
128 fn current_event(&self) -> Option<&Event<'_>> {
129 self.current().map(|(event, _)| event)
130 }
131
132 fn is_text_like(event: &Event) -> bool {
133 match event {
134 Event::Text(_)
135 // Represent an inline code block
136 | Event::Code(_)
137 | Event::Html(_)
138 | Event::InlineHtml(_)
139 | Event::FootnoteReference(_)
140 | Event::Start(Tag::Link { .. })
141 | Event::Start(Tag::Emphasis)
142 | Event::Start(Tag::Strong)
143 | Event::Start(Tag::Strikethrough)
144 | Event::Start(Tag::Image { .. }) => {
145 true
146 }
147 _ => false,
148 }
149 }
150
151 async fn parse_document(mut self) -> Self {
152 while !self.eof() {
153 if let Some(block) = self.parse_block().await {
154 self.parsed.extend(block);
155 } else {
156 self.cursor += 1;
157 }
158 }
159 self
160 }
161
162 #[async_recursion]
163 async fn parse_block(&mut self) -> Option<Vec<ParsedMarkdownElement>> {
164 let (current, source_range) = self.current().unwrap();
165 let source_range = source_range.clone();
166 match current {
167 Event::Start(tag) => match tag {
168 Tag::Paragraph => {
169 self.cursor += 1;
170 let text = self.parse_text(false, Some(source_range));
171 Some(vec![ParsedMarkdownElement::Paragraph(text)])
172 }
173 Tag::Heading { level, .. } => {
174 let level = *level;
175 self.cursor += 1;
176 let heading = self.parse_heading(level);
177 Some(vec![ParsedMarkdownElement::Heading(heading)])
178 }
179 Tag::Table(alignment) => {
180 let alignment = alignment.clone();
181 self.cursor += 1;
182 let table = self.parse_table(alignment);
183 Some(vec![ParsedMarkdownElement::Table(table)])
184 }
185 Tag::List(order) => {
186 let order = *order;
187 self.cursor += 1;
188 let list = self.parse_list(order).await;
189 Some(list)
190 }
191 Tag::BlockQuote(_kind) => {
192 self.cursor += 1;
193 let block_quote = self.parse_block_quote().await;
194 Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)])
195 }
196 Tag::CodeBlock(kind) => {
197 let (language, scale) = match kind {
198 pulldown_cmark::CodeBlockKind::Indented => (None, None),
199 pulldown_cmark::CodeBlockKind::Fenced(language) => {
200 if language.is_empty() {
201 (None, None)
202 } else {
203 let parts: Vec<&str> = language.split_whitespace().collect();
204 let lang = parts.first().map(|s| s.to_string());
205 let scale = parts.get(1).and_then(|s| s.parse::<u32>().ok());
206 (lang, scale)
207 }
208 }
209 };
210
211 self.cursor += 1;
212
213 if language.as_deref() == Some("mermaid") {
214 let mermaid_diagram = self.parse_mermaid_diagram(scale).await?;
215 Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)])
216 } else {
217 let code_block = self.parse_code_block(language).await?;
218 Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
219 }
220 }
221 Tag::HtmlBlock => {
222 self.cursor += 1;
223
224 Some(self.parse_html_block().await)
225 }
226 _ => None,
227 },
228 Event::Rule => {
229 self.cursor += 1;
230 Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)])
231 }
232 _ => None,
233 }
234 }
235
236 fn parse_text(
237 &mut self,
238 should_complete_on_soft_break: bool,
239 source_range: Option<Range<usize>>,
240 ) -> MarkdownParagraph {
241 let source_range = source_range.unwrap_or_else(|| {
242 self.current()
243 .map(|(_, range)| range.clone())
244 .unwrap_or_default()
245 });
246
247 let mut markdown_text_like = Vec::new();
248 let mut text = String::new();
249 let mut bold_depth = 0;
250 let mut italic_depth = 0;
251 let mut strikethrough_depth = 0;
252 let mut link: Option<Link> = None;
253 let mut image: Option<Image> = None;
254 let mut regions: Vec<(Range<usize>, ParsedRegion)> = vec![];
255 let mut highlights: Vec<(Range<usize>, MarkdownHighlight)> = vec![];
256 let mut link_urls: Vec<String> = vec![];
257 let mut link_ranges: Vec<Range<usize>> = vec![];
258
259 loop {
260 if self.eof() {
261 break;
262 }
263
264 let (current, _) = self.current().unwrap();
265 let prev_len = text.len();
266 match current {
267 Event::SoftBreak => {
268 if should_complete_on_soft_break {
269 break;
270 }
271 text.push(' ');
272 }
273
274 Event::HardBreak => {
275 text.push('\n');
276 }
277
278 // We want to ignore any inline HTML tags in the text but keep
279 // the text between them
280 Event::InlineHtml(_) => {}
281
282 Event::Text(t) => {
283 text.push_str(t.as_ref());
284 let mut style = MarkdownHighlightStyle::default();
285
286 if bold_depth > 0 {
287 style.weight = FontWeight::BOLD;
288 }
289
290 if italic_depth > 0 {
291 style.italic = true;
292 }
293
294 if strikethrough_depth > 0 {
295 style.strikethrough = true;
296 }
297
298 let last_run_len = if let Some(link) = link.clone() {
299 regions.push((
300 prev_len..text.len(),
301 ParsedRegion {
302 code: false,
303 link: Some(link),
304 },
305 ));
306 style.link = true;
307 prev_len
308 } else {
309 // Manually scan for links
310 let mut finder = linkify::LinkFinder::new();
311 finder.kinds(&[linkify::LinkKind::Url]);
312 let mut last_link_len = prev_len;
313 for link in finder.links(t) {
314 let start = prev_len + link.start();
315 let end = prev_len + link.end();
316 let range = start..end;
317 link_ranges.push(range.clone());
318 link_urls.push(link.as_str().to_string());
319
320 // If there is a style before we match a link, we have to add this to the highlighted ranges
321 if style != MarkdownHighlightStyle::default() && last_link_len < start {
322 highlights.push((
323 last_link_len..start,
324 MarkdownHighlight::Style(style.clone()),
325 ));
326 }
327
328 highlights.push((
329 range.clone(),
330 MarkdownHighlight::Style(MarkdownHighlightStyle {
331 underline: true,
332 ..style
333 }),
334 ));
335
336 regions.push((
337 range.clone(),
338 ParsedRegion {
339 code: false,
340 link: Some(Link::Web {
341 url: link.as_str().to_string(),
342 }),
343 },
344 ));
345 last_link_len = end;
346 }
347 last_link_len
348 };
349
350 if style != MarkdownHighlightStyle::default() && last_run_len < text.len() {
351 let mut new_highlight = true;
352 if let Some((last_range, last_style)) = highlights.last_mut()
353 && last_range.end == last_run_len
354 && last_style == &MarkdownHighlight::Style(style.clone())
355 {
356 last_range.end = text.len();
357 new_highlight = false;
358 }
359 if new_highlight {
360 highlights.push((
361 last_run_len..text.len(),
362 MarkdownHighlight::Style(style.clone()),
363 ));
364 }
365 }
366 }
367 Event::Code(t) => {
368 text.push_str(t.as_ref());
369 let range = prev_len..text.len();
370
371 if link.is_some() {
372 highlights.push((
373 range.clone(),
374 MarkdownHighlight::Style(MarkdownHighlightStyle {
375 link: true,
376 ..Default::default()
377 }),
378 ));
379 }
380 regions.push((
381 range,
382 ParsedRegion {
383 code: true,
384 link: link.clone(),
385 },
386 ));
387 }
388 Event::Start(tag) => match tag {
389 Tag::Emphasis => italic_depth += 1,
390 Tag::Strong => bold_depth += 1,
391 Tag::Strikethrough => strikethrough_depth += 1,
392 Tag::Link { dest_url, .. } => {
393 link = Link::identify(
394 self.file_location_directory.clone(),
395 dest_url.to_string(),
396 );
397 }
398 Tag::Image { dest_url, .. } => {
399 if !text.is_empty() {
400 let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText {
401 source_range: source_range.clone(),
402 contents: mem::take(&mut text).into(),
403 highlights: mem::take(&mut highlights),
404 regions: mem::take(&mut regions),
405 });
406 markdown_text_like.push(parsed_regions);
407 }
408 image = Image::identify(
409 dest_url.to_string(),
410 source_range.clone(),
411 self.file_location_directory.clone(),
412 );
413 }
414 _ => {
415 break;
416 }
417 },
418
419 Event::End(tag) => match tag {
420 TagEnd::Emphasis => italic_depth -= 1,
421 TagEnd::Strong => bold_depth -= 1,
422 TagEnd::Strikethrough => strikethrough_depth -= 1,
423 TagEnd::Link => {
424 link = None;
425 }
426 TagEnd::Image => {
427 if let Some(mut image) = image.take() {
428 if !text.is_empty() {
429 image.set_alt_text(std::mem::take(&mut text).into());
430 mem::take(&mut highlights);
431 mem::take(&mut regions);
432 }
433 markdown_text_like.push(MarkdownParagraphChunk::Image(image));
434 }
435 }
436 TagEnd::Paragraph => {
437 self.cursor += 1;
438 break;
439 }
440 _ => {
441 break;
442 }
443 },
444 _ => {
445 break;
446 }
447 }
448
449 self.cursor += 1;
450 }
451 if !text.is_empty() {
452 markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
453 source_range,
454 contents: text.into(),
455 highlights,
456 regions,
457 }));
458 }
459 markdown_text_like
460 }
461
462 fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading {
463 let (_event, source_range) = self.previous().unwrap();
464 let source_range = source_range.clone();
465 let text = self.parse_text(true, None);
466
467 // Advance past the heading end tag
468 self.cursor += 1;
469
470 ParsedMarkdownHeading {
471 source_range,
472 level: match level {
473 pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1,
474 pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2,
475 pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3,
476 pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4,
477 pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5,
478 pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6,
479 },
480 contents: text,
481 }
482 }
483
484 fn parse_table(&mut self, alignment: Vec<Alignment>) -> ParsedMarkdownTable {
485 let (_event, source_range) = self.previous().unwrap();
486 let source_range = source_range.clone();
487 let mut header = vec![];
488 let mut body = vec![];
489 let mut row_columns = vec![];
490 let mut in_header = true;
491 let column_alignments = alignment
492 .iter()
493 .map(Self::convert_alignment)
494 .collect::<Vec<_>>();
495
496 loop {
497 if self.eof() {
498 break;
499 }
500
501 let (current, source_range) = self.current().unwrap();
502 let source_range = source_range.clone();
503 match current {
504 Event::Start(Tag::TableHead)
505 | Event::Start(Tag::TableRow)
506 | Event::End(TagEnd::TableCell) => {
507 self.cursor += 1;
508 }
509 Event::Start(Tag::TableCell) => {
510 self.cursor += 1;
511 let cell_contents = self.parse_text(false, Some(source_range));
512 row_columns.push(ParsedMarkdownTableColumn {
513 col_span: 1,
514 row_span: 1,
515 is_header: in_header,
516 children: cell_contents,
517 alignment: column_alignments
518 .get(row_columns.len())
519 .copied()
520 .unwrap_or_default(),
521 });
522 }
523 Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
524 self.cursor += 1;
525 let columns = std::mem::take(&mut row_columns);
526 if in_header {
527 header.push(ParsedMarkdownTableRow { columns: columns });
528 in_header = false;
529 } else {
530 body.push(ParsedMarkdownTableRow::with_columns(columns));
531 }
532 }
533 Event::End(TagEnd::Table) => {
534 self.cursor += 1;
535 break;
536 }
537 _ => {
538 break;
539 }
540 }
541 }
542
543 ParsedMarkdownTable {
544 source_range,
545 header,
546 body,
547 caption: None,
548 }
549 }
550
551 fn convert_alignment(alignment: &Alignment) -> ParsedMarkdownTableAlignment {
552 match alignment {
553 Alignment::None => ParsedMarkdownTableAlignment::None,
554 Alignment::Left => ParsedMarkdownTableAlignment::Left,
555 Alignment::Center => ParsedMarkdownTableAlignment::Center,
556 Alignment::Right => ParsedMarkdownTableAlignment::Right,
557 }
558 }
559
560 async fn parse_list(&mut self, order: Option<u64>) -> Vec<ParsedMarkdownElement> {
561 let (_, list_source_range) = self.previous().unwrap();
562
563 let mut items = Vec::new();
564 let mut items_stack = vec![MarkdownListItem::default()];
565 let mut depth = 1;
566 let mut order = order;
567 let mut order_stack = Vec::new();
568
569 let mut insertion_indices = FxHashMap::default();
570 let mut source_ranges = FxHashMap::default();
571 let mut start_item_range = list_source_range.clone();
572
573 while !self.eof() {
574 let (current, source_range) = self.current().unwrap();
575 match current {
576 Event::Start(Tag::List(new_order)) => {
577 if items_stack.last().is_some() && !insertion_indices.contains_key(&depth) {
578 insertion_indices.insert(depth, items.len());
579 }
580
581 // We will use the start of the nested list as the end for the current item's range,
582 // because we don't care about the hierarchy of list items
583 if let collections::hash_map::Entry::Vacant(e) = source_ranges.entry(depth) {
584 e.insert(start_item_range.start..source_range.start);
585 }
586
587 order_stack.push(order);
588 order = *new_order;
589 self.cursor += 1;
590 depth += 1;
591 }
592 Event::End(TagEnd::List(_)) => {
593 order = order_stack.pop().flatten();
594 self.cursor += 1;
595 depth -= 1;
596
597 if depth == 0 {
598 break;
599 }
600 }
601 Event::Start(Tag::Item) => {
602 start_item_range = source_range.clone();
603
604 self.cursor += 1;
605 items_stack.push(MarkdownListItem::default());
606
607 let mut task_list = None;
608 // Check for task list marker (`- [ ]` or `- [x]`)
609 if let Some(event) = self.current_event() {
610 // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph
611 if event == &Event::Start(Tag::Paragraph) {
612 self.cursor += 1;
613 }
614
615 if let Some((Event::TaskListMarker(checked), range)) = self.current() {
616 task_list = Some((*checked, range.clone()));
617 self.cursor += 1;
618 }
619 }
620
621 if let Some((event, range)) = self.current() {
622 // This is a plain list item.
623 // For example `- some text` or `1. [Docs](./docs.md)`
624 if MarkdownParser::is_text_like(event) {
625 let text = self.parse_text(false, Some(range.clone()));
626 let block = ParsedMarkdownElement::Paragraph(text);
627 if let Some(content) = items_stack.last_mut() {
628 let item_type = if let Some((checked, range)) = task_list {
629 ParsedMarkdownListItemType::Task(checked, range)
630 } else if let Some(order) = order {
631 ParsedMarkdownListItemType::Ordered(order)
632 } else {
633 ParsedMarkdownListItemType::Unordered
634 };
635 content.item_type = item_type;
636 content.content.push(block);
637 }
638 } else {
639 let block = self.parse_block().await;
640 if let Some(block) = block
641 && let Some(list_item) = items_stack.last_mut()
642 {
643 list_item.content.extend(block);
644 }
645 }
646 }
647
648 // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph
649 if self.current_event() == Some(&Event::End(TagEnd::Paragraph)) {
650 self.cursor += 1;
651 }
652 }
653 Event::End(TagEnd::Item) => {
654 self.cursor += 1;
655
656 if let Some(current) = order {
657 order = Some(current + 1);
658 }
659
660 if let Some(list_item) = items_stack.pop() {
661 let source_range = source_ranges
662 .remove(&depth)
663 .unwrap_or(start_item_range.clone());
664
665 // We need to remove the last character of the source range, because it includes the newline character
666 let source_range = source_range.start..source_range.end - 1;
667 let item = ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
668 source_range,
669 content: list_item.content,
670 depth,
671 item_type: list_item.item_type,
672 nested: false,
673 });
674
675 if let Some(index) = insertion_indices.get(&depth) {
676 items.insert(*index, item);
677 insertion_indices.remove(&depth);
678 } else {
679 items.push(item);
680 }
681 }
682 }
683 _ => {
684 if depth == 0 {
685 break;
686 }
687 // This can only happen if a list item starts with more then one paragraph,
688 // or the list item contains blocks that should be rendered after the nested list items
689 let block = self.parse_block().await;
690 if let Some(block) = block {
691 if let Some(list_item) = items_stack.last_mut() {
692 // If we did not insert any nested items yet (in this case insertion index is set), we can append the block to the current list item
693 if !insertion_indices.contains_key(&depth) {
694 list_item.content.extend(block);
695 continue;
696 }
697 }
698
699 // Otherwise we need to insert the block after all the nested items
700 // that have been parsed so far
701 items.extend(block);
702 } else {
703 self.cursor += 1;
704 }
705 }
706 }
707 }
708
709 items
710 }
711
712 #[async_recursion]
713 async fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote {
714 let (_event, source_range) = self.previous().unwrap();
715 let source_range = source_range.clone();
716 let mut nested_depth = 1;
717
718 let mut children: Vec<ParsedMarkdownElement> = vec![];
719
720 while !self.eof() {
721 let block = self.parse_block().await;
722
723 if let Some(block) = block {
724 children.extend(block);
725 } else {
726 break;
727 }
728
729 if self.eof() {
730 break;
731 }
732
733 let (current, _source_range) = self.current().unwrap();
734 match current {
735 // This is a nested block quote.
736 // Record that we're in a nested block quote and continue parsing.
737 // We don't need to advance the cursor since the next
738 // call to `parse_block` will handle it.
739 Event::Start(Tag::BlockQuote(_kind)) => {
740 nested_depth += 1;
741 }
742 Event::End(TagEnd::BlockQuote(_kind)) => {
743 nested_depth -= 1;
744 if nested_depth == 0 {
745 self.cursor += 1;
746 break;
747 }
748 }
749 _ => {}
750 };
751 }
752
753 ParsedMarkdownBlockQuote {
754 source_range,
755 children,
756 }
757 }
758
759 async fn parse_code_block(
760 &mut self,
761 language: Option<String>,
762 ) -> Option<ParsedMarkdownCodeBlock> {
763 let Some((_event, source_range)) = self.previous() else {
764 return None;
765 };
766
767 let source_range = source_range.clone();
768 let mut code = String::new();
769
770 while !self.eof() {
771 let Some((current, _source_range)) = self.current() else {
772 break;
773 };
774
775 match current {
776 Event::Text(text) => {
777 code.push_str(text);
778 self.cursor += 1;
779 }
780 Event::End(TagEnd::CodeBlock) => {
781 self.cursor += 1;
782 break;
783 }
784 _ => {
785 break;
786 }
787 }
788 }
789
790 code = code.strip_suffix('\n').unwrap_or(&code).to_string();
791
792 let highlights = if let Some(language) = &language {
793 if let Some(registry) = &self.language_registry {
794 let rope: language::Rope = code.as_str().into();
795 registry
796 .language_for_name_or_extension(language)
797 .await
798 .map(|l| l.highlight_text(&rope, 0..code.len()))
799 .ok()
800 } else {
801 None
802 }
803 } else {
804 None
805 };
806
807 Some(ParsedMarkdownCodeBlock {
808 source_range,
809 contents: code.into(),
810 language,
811 highlights,
812 })
813 }
814
815 async fn parse_mermaid_diagram(
816 &mut self,
817 scale: Option<u32>,
818 ) -> Option<ParsedMarkdownMermaidDiagram> {
819 let Some((_event, source_range)) = self.previous() else {
820 return None;
821 };
822
823 let source_range = source_range.clone();
824 let mut code = String::new();
825
826 while !self.eof() {
827 let Some((current, _source_range)) = self.current() else {
828 break;
829 };
830
831 match current {
832 Event::Text(text) => {
833 code.push_str(text);
834 self.cursor += 1;
835 }
836 Event::End(TagEnd::CodeBlock) => {
837 self.cursor += 1;
838 break;
839 }
840 _ => {
841 break;
842 }
843 }
844 }
845
846 code = code.strip_suffix('\n').unwrap_or(&code).to_string();
847
848 let scale = scale.unwrap_or(100).clamp(10, 500);
849
850 Some(ParsedMarkdownMermaidDiagram {
851 source_range,
852 contents: ParsedMarkdownMermaidDiagramContents {
853 contents: code.into(),
854 scale,
855 },
856 })
857 }
858
859 async fn parse_html_block(&mut self) -> Vec<ParsedMarkdownElement> {
860 let mut elements = Vec::new();
861 let Some((_event, _source_range)) = self.previous() else {
862 return elements;
863 };
864
865 let mut html_source_range_start = None;
866 let mut html_source_range_end = None;
867 let mut html_buffer = String::new();
868
869 while !self.eof() {
870 let Some((current, source_range)) = self.current() else {
871 break;
872 };
873 let source_range = source_range.clone();
874 match current {
875 Event::Html(html) => {
876 html_source_range_start.get_or_insert(source_range.start);
877 html_source_range_end = Some(source_range.end);
878 html_buffer.push_str(html);
879 self.cursor += 1;
880 }
881 Event::End(TagEnd::CodeBlock) => {
882 self.cursor += 1;
883 break;
884 }
885 _ => {
886 break;
887 }
888 }
889 }
890
891 let bytes = cleanup_html(&html_buffer);
892
893 let mut cursor = std::io::Cursor::new(bytes);
894 if let Ok(dom) = parse_document(RcDom::default(), ParseOpts::default())
895 .from_utf8()
896 .read_from(&mut cursor)
897 && let Some((start, end)) = html_source_range_start.zip(html_source_range_end)
898 {
899 self.parse_html_node(
900 start..end,
901 &dom.document,
902 &mut elements,
903 &ParseHtmlNodeContext::default(),
904 );
905 }
906
907 elements
908 }
909
910 fn parse_html_node(
911 &self,
912 source_range: Range<usize>,
913 node: &Rc<markup5ever_rcdom::Node>,
914 elements: &mut Vec<ParsedMarkdownElement>,
915 context: &ParseHtmlNodeContext,
916 ) {
917 match &node.data {
918 markup5ever_rcdom::NodeData::Document => {
919 self.consume_children(source_range, node, elements, context);
920 }
921 markup5ever_rcdom::NodeData::Text { contents } => {
922 elements.push(ParsedMarkdownElement::Paragraph(vec![
923 MarkdownParagraphChunk::Text(ParsedMarkdownText {
924 source_range,
925 regions: Vec::default(),
926 highlights: Vec::default(),
927 contents: contents.borrow().to_string().into(),
928 }),
929 ]));
930 }
931 markup5ever_rcdom::NodeData::Comment { .. } => {}
932 markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
933 let mut styles = if let Some(styles) = Self::markdown_style_from_html_styles(
934 Self::extract_styles_from_attributes(attrs),
935 ) {
936 vec![MarkdownHighlight::Style(styles)]
937 } else {
938 Vec::default()
939 };
940
941 if local_name!("img") == name.local {
942 if let Some(image) = self.extract_image(source_range, attrs) {
943 elements.push(ParsedMarkdownElement::Image(image));
944 }
945 } else if local_name!("p") == name.local {
946 let mut paragraph = MarkdownParagraph::new();
947 self.parse_paragraph(
948 source_range,
949 node,
950 &mut paragraph,
951 &mut styles,
952 &mut Vec::new(),
953 );
954
955 if !paragraph.is_empty() {
956 elements.push(ParsedMarkdownElement::Paragraph(paragraph));
957 }
958 } else if matches!(
959 name.local,
960 local_name!("h1")
961 | local_name!("h2")
962 | local_name!("h3")
963 | local_name!("h4")
964 | local_name!("h5")
965 | local_name!("h6")
966 ) {
967 let mut paragraph = MarkdownParagraph::new();
968 self.consume_paragraph(
969 source_range.clone(),
970 node,
971 &mut paragraph,
972 &mut styles,
973 &mut Vec::new(),
974 );
975
976 if !paragraph.is_empty() {
977 elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
978 source_range,
979 level: match name.local {
980 local_name!("h1") => HeadingLevel::H1,
981 local_name!("h2") => HeadingLevel::H2,
982 local_name!("h3") => HeadingLevel::H3,
983 local_name!("h4") => HeadingLevel::H4,
984 local_name!("h5") => HeadingLevel::H5,
985 local_name!("h6") => HeadingLevel::H6,
986 _ => unreachable!(),
987 },
988 contents: paragraph,
989 }));
990 }
991 } else if local_name!("ul") == name.local || local_name!("ol") == name.local {
992 if let Some(list_items) = self.extract_html_list(
993 node,
994 local_name!("ol") == name.local,
995 context.list_item_depth,
996 source_range,
997 ) {
998 elements.extend(list_items);
999 }
1000 } else if local_name!("blockquote") == name.local {
1001 if let Some(blockquote) = self.extract_html_blockquote(node, source_range) {
1002 elements.push(ParsedMarkdownElement::BlockQuote(blockquote));
1003 }
1004 } else if local_name!("table") == name.local {
1005 if let Some(table) = self.extract_html_table(node, source_range) {
1006 elements.push(ParsedMarkdownElement::Table(table));
1007 }
1008 } else {
1009 self.consume_children(source_range, node, elements, context);
1010 }
1011 }
1012 _ => {}
1013 }
1014 }
1015
1016 fn parse_paragraph(
1017 &self,
1018 source_range: Range<usize>,
1019 node: &Rc<markup5ever_rcdom::Node>,
1020 paragraph: &mut MarkdownParagraph,
1021 highlights: &mut Vec<MarkdownHighlight>,
1022 regions: &mut Vec<(Range<usize>, ParsedRegion)>,
1023 ) {
1024 fn items_with_range<T>(
1025 range: Range<usize>,
1026 items: impl IntoIterator<Item = T>,
1027 ) -> Vec<(Range<usize>, T)> {
1028 items
1029 .into_iter()
1030 .map(|item| (range.clone(), item))
1031 .collect()
1032 }
1033
1034 match &node.data {
1035 markup5ever_rcdom::NodeData::Text { contents } => {
1036 // append the text to the last chunk, so we can have a hacky version
1037 // of inline text with highlighting
1038 if let Some(text) = paragraph.iter_mut().last().and_then(|p| match p {
1039 MarkdownParagraphChunk::Text(text) => Some(text),
1040 _ => None,
1041 }) {
1042 let mut new_text = text.contents.to_string();
1043 new_text.push_str(&contents.borrow());
1044
1045 text.highlights.extend(items_with_range(
1046 text.contents.len()..new_text.len(),
1047 std::mem::take(highlights),
1048 ));
1049 text.regions.extend(items_with_range(
1050 text.contents.len()..new_text.len(),
1051 std::mem::take(regions)
1052 .into_iter()
1053 .map(|(_, region)| region),
1054 ));
1055 text.contents = SharedString::from(new_text);
1056 } else {
1057 let contents = contents.borrow().to_string();
1058 paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
1059 source_range,
1060 highlights: items_with_range(0..contents.len(), std::mem::take(highlights)),
1061 regions: items_with_range(
1062 0..contents.len(),
1063 std::mem::take(regions)
1064 .into_iter()
1065 .map(|(_, region)| region),
1066 ),
1067 contents: contents.into(),
1068 }));
1069 }
1070 }
1071 markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
1072 if local_name!("img") == name.local {
1073 if let Some(image) = self.extract_image(source_range, attrs) {
1074 paragraph.push(MarkdownParagraphChunk::Image(image));
1075 }
1076 } else if local_name!("b") == name.local || local_name!("strong") == name.local {
1077 highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1078 weight: FontWeight::BOLD,
1079 ..Default::default()
1080 }));
1081
1082 self.consume_paragraph(source_range, node, paragraph, highlights, regions);
1083 } else if local_name!("i") == name.local {
1084 highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1085 italic: true,
1086 ..Default::default()
1087 }));
1088
1089 self.consume_paragraph(source_range, node, paragraph, highlights, regions);
1090 } else if local_name!("em") == name.local {
1091 highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1092 oblique: true,
1093 ..Default::default()
1094 }));
1095
1096 self.consume_paragraph(source_range, node, paragraph, highlights, regions);
1097 } else if local_name!("del") == name.local {
1098 highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1099 strikethrough: true,
1100 ..Default::default()
1101 }));
1102
1103 self.consume_paragraph(source_range, node, paragraph, highlights, regions);
1104 } else if local_name!("ins") == name.local {
1105 highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1106 underline: true,
1107 ..Default::default()
1108 }));
1109
1110 self.consume_paragraph(source_range, node, paragraph, highlights, regions);
1111 } else if local_name!("a") == name.local {
1112 if let Some(url) = Self::attr_value(attrs, local_name!("href"))
1113 && let Some(link) =
1114 Link::identify(self.file_location_directory.clone(), url)
1115 {
1116 highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
1117 link: true,
1118 ..Default::default()
1119 }));
1120
1121 regions.push((
1122 source_range.clone(),
1123 ParsedRegion {
1124 code: false,
1125 link: Some(link),
1126 },
1127 ));
1128 }
1129
1130 self.consume_paragraph(source_range, node, paragraph, highlights, regions);
1131 } else {
1132 self.consume_paragraph(source_range, node, paragraph, highlights, regions);
1133 }
1134 }
1135 _ => {}
1136 }
1137 }
1138
1139 fn consume_paragraph(
1140 &self,
1141 source_range: Range<usize>,
1142 node: &Rc<markup5ever_rcdom::Node>,
1143 paragraph: &mut MarkdownParagraph,
1144 highlights: &mut Vec<MarkdownHighlight>,
1145 regions: &mut Vec<(Range<usize>, ParsedRegion)>,
1146 ) {
1147 for node in node.children.borrow().iter() {
1148 self.parse_paragraph(source_range.clone(), node, paragraph, highlights, regions);
1149 }
1150 }
1151
1152 fn parse_table_row(
1153 &self,
1154 source_range: Range<usize>,
1155 node: &Rc<markup5ever_rcdom::Node>,
1156 ) -> Option<ParsedMarkdownTableRow> {
1157 let mut columns = Vec::new();
1158
1159 match &node.data {
1160 markup5ever_rcdom::NodeData::Element { name, .. } => {
1161 if local_name!("tr") != name.local {
1162 return None;
1163 }
1164
1165 for node in node.children.borrow().iter() {
1166 if let Some(column) = self.parse_table_column(source_range.clone(), node) {
1167 columns.push(column);
1168 }
1169 }
1170 }
1171 _ => {}
1172 }
1173
1174 if columns.is_empty() {
1175 None
1176 } else {
1177 Some(ParsedMarkdownTableRow { columns })
1178 }
1179 }
1180
1181 fn parse_table_column(
1182 &self,
1183 source_range: Range<usize>,
1184 node: &Rc<markup5ever_rcdom::Node>,
1185 ) -> Option<ParsedMarkdownTableColumn> {
1186 match &node.data {
1187 markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
1188 if !matches!(name.local, local_name!("th") | local_name!("td")) {
1189 return None;
1190 }
1191
1192 let mut children = MarkdownParagraph::new();
1193 self.consume_paragraph(
1194 source_range,
1195 node,
1196 &mut children,
1197 &mut Vec::new(),
1198 &mut Vec::new(),
1199 );
1200
1201 let is_header = matches!(name.local, local_name!("th"));
1202
1203 Some(ParsedMarkdownTableColumn {
1204 col_span: std::cmp::max(
1205 Self::attr_value(attrs, local_name!("colspan"))
1206 .and_then(|span| span.parse().ok())
1207 .unwrap_or(1),
1208 1,
1209 ),
1210 row_span: std::cmp::max(
1211 Self::attr_value(attrs, local_name!("rowspan"))
1212 .and_then(|span| span.parse().ok())
1213 .unwrap_or(1),
1214 1,
1215 ),
1216 is_header,
1217 children,
1218 alignment: Self::attr_value(attrs, local_name!("align"))
1219 .and_then(|align| match align.as_str() {
1220 "left" => Some(ParsedMarkdownTableAlignment::Left),
1221 "center" => Some(ParsedMarkdownTableAlignment::Center),
1222 "right" => Some(ParsedMarkdownTableAlignment::Right),
1223 _ => None,
1224 })
1225 .unwrap_or_else(|| {
1226 if is_header {
1227 ParsedMarkdownTableAlignment::Center
1228 } else {
1229 ParsedMarkdownTableAlignment::default()
1230 }
1231 }),
1232 })
1233 }
1234 _ => None,
1235 }
1236 }
1237
1238 fn consume_children(
1239 &self,
1240 source_range: Range<usize>,
1241 node: &Rc<markup5ever_rcdom::Node>,
1242 elements: &mut Vec<ParsedMarkdownElement>,
1243 context: &ParseHtmlNodeContext,
1244 ) {
1245 for node in node.children.borrow().iter() {
1246 self.parse_html_node(source_range.clone(), node, elements, context);
1247 }
1248 }
1249
1250 fn attr_value(
1251 attrs: &RefCell<Vec<html5ever::Attribute>>,
1252 name: html5ever::LocalName,
1253 ) -> Option<String> {
1254 attrs.borrow().iter().find_map(|attr| {
1255 if attr.name.local == name {
1256 Some(attr.value.to_string())
1257 } else {
1258 None
1259 }
1260 })
1261 }
1262
1263 fn markdown_style_from_html_styles(
1264 styles: HashMap<String, String>,
1265 ) -> Option<MarkdownHighlightStyle> {
1266 let mut markdown_style = MarkdownHighlightStyle::default();
1267
1268 if let Some(text_decoration) = styles.get("text-decoration") {
1269 match text_decoration.to_lowercase().as_str() {
1270 "underline" => {
1271 markdown_style.underline = true;
1272 }
1273 "line-through" => {
1274 markdown_style.strikethrough = true;
1275 }
1276 _ => {}
1277 }
1278 }
1279
1280 if let Some(font_style) = styles.get("font-style") {
1281 match font_style.to_lowercase().as_str() {
1282 "italic" => {
1283 markdown_style.italic = true;
1284 }
1285 "oblique" => {
1286 markdown_style.oblique = true;
1287 }
1288 _ => {}
1289 }
1290 }
1291
1292 if let Some(font_weight) = styles.get("font-weight") {
1293 match font_weight.to_lowercase().as_str() {
1294 "bold" => {
1295 markdown_style.weight = FontWeight::BOLD;
1296 }
1297 "lighter" => {
1298 markdown_style.weight = FontWeight::THIN;
1299 }
1300 _ => {
1301 if let Some(weight) = font_weight.parse::<f32>().ok() {
1302 markdown_style.weight = FontWeight(weight);
1303 }
1304 }
1305 }
1306 }
1307
1308 if markdown_style != MarkdownHighlightStyle::default() {
1309 Some(markdown_style)
1310 } else {
1311 None
1312 }
1313 }
1314
1315 fn extract_styles_from_attributes(
1316 attrs: &RefCell<Vec<html5ever::Attribute>>,
1317 ) -> HashMap<String, String> {
1318 let mut styles = HashMap::new();
1319
1320 if let Some(style) = Self::attr_value(attrs, local_name!("style")) {
1321 for decl in style.split(';') {
1322 let mut parts = decl.splitn(2, ':');
1323 if let Some((key, value)) = parts.next().zip(parts.next()) {
1324 styles.insert(
1325 key.trim().to_lowercase().to_string(),
1326 value.trim().to_string(),
1327 );
1328 }
1329 }
1330 }
1331
1332 styles
1333 }
1334
1335 fn extract_image(
1336 &self,
1337 source_range: Range<usize>,
1338 attrs: &RefCell<Vec<html5ever::Attribute>>,
1339 ) -> Option<Image> {
1340 let src = Self::attr_value(attrs, local_name!("src"))?;
1341
1342 let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?;
1343
1344 if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) {
1345 image.set_alt_text(alt.into());
1346 }
1347
1348 let styles = Self::extract_styles_from_attributes(attrs);
1349
1350 if let Some(width) = Self::attr_value(attrs, local_name!("width"))
1351 .or_else(|| styles.get("width").cloned())
1352 .and_then(|width| Self::parse_html_element_dimension(&width))
1353 {
1354 image.set_width(width);
1355 }
1356
1357 if let Some(height) = Self::attr_value(attrs, local_name!("height"))
1358 .or_else(|| styles.get("height").cloned())
1359 .and_then(|height| Self::parse_html_element_dimension(&height))
1360 {
1361 image.set_height(height);
1362 }
1363
1364 Some(image)
1365 }
1366
1367 fn extract_html_list(
1368 &self,
1369 node: &Rc<markup5ever_rcdom::Node>,
1370 ordered: bool,
1371 depth: u16,
1372 source_range: Range<usize>,
1373 ) -> Option<Vec<ParsedMarkdownElement>> {
1374 let mut list_items = Vec::with_capacity(node.children.borrow().len());
1375
1376 for (index, node) in node.children.borrow().iter().enumerate() {
1377 match &node.data {
1378 markup5ever_rcdom::NodeData::Element { name, .. } => {
1379 if local_name!("li") != name.local {
1380 continue;
1381 }
1382
1383 let mut content = Vec::new();
1384 self.consume_children(
1385 source_range.clone(),
1386 node,
1387 &mut content,
1388 &ParseHtmlNodeContext {
1389 list_item_depth: depth + 1,
1390 },
1391 );
1392
1393 if !content.is_empty() {
1394 list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
1395 depth,
1396 source_range: source_range.clone(),
1397 item_type: if ordered {
1398 ParsedMarkdownListItemType::Ordered(index as u64 + 1)
1399 } else {
1400 ParsedMarkdownListItemType::Unordered
1401 },
1402 content,
1403 nested: true,
1404 }));
1405 }
1406 }
1407 _ => {}
1408 }
1409 }
1410
1411 if list_items.is_empty() {
1412 None
1413 } else {
1414 Some(list_items)
1415 }
1416 }
1417
1418 fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
1419 if value.ends_with("%") {
1420 value
1421 .trim_end_matches("%")
1422 .parse::<f32>()
1423 .ok()
1424 .map(|value| relative(value / 100.))
1425 } else {
1426 value
1427 .trim_end_matches("px")
1428 .parse()
1429 .ok()
1430 .map(|value| px(value).into())
1431 }
1432 }
1433
1434 fn extract_html_blockquote(
1435 &self,
1436 node: &Rc<markup5ever_rcdom::Node>,
1437 source_range: Range<usize>,
1438 ) -> Option<ParsedMarkdownBlockQuote> {
1439 let mut children = Vec::new();
1440 self.consume_children(
1441 source_range.clone(),
1442 node,
1443 &mut children,
1444 &ParseHtmlNodeContext::default(),
1445 );
1446
1447 if children.is_empty() {
1448 None
1449 } else {
1450 Some(ParsedMarkdownBlockQuote {
1451 children,
1452 source_range,
1453 })
1454 }
1455 }
1456
1457 fn extract_html_table(
1458 &self,
1459 node: &Rc<markup5ever_rcdom::Node>,
1460 source_range: Range<usize>,
1461 ) -> Option<ParsedMarkdownTable> {
1462 let mut header_rows = Vec::new();
1463 let mut body_rows = Vec::new();
1464 let mut caption = None;
1465
1466 // node should be a thead, tbody or caption element
1467 for node in node.children.borrow().iter() {
1468 match &node.data {
1469 markup5ever_rcdom::NodeData::Element { name, .. } => {
1470 if local_name!("caption") == name.local {
1471 let mut paragraph = MarkdownParagraph::new();
1472 self.parse_paragraph(
1473 source_range.clone(),
1474 node,
1475 &mut paragraph,
1476 &mut Vec::new(),
1477 &mut Vec::new(),
1478 );
1479 caption = Some(paragraph);
1480 }
1481 if local_name!("thead") == name.local {
1482 // node should be a tr element
1483 for node in node.children.borrow().iter() {
1484 if let Some(row) = self.parse_table_row(source_range.clone(), node) {
1485 header_rows.push(row);
1486 }
1487 }
1488 } else if local_name!("tbody") == name.local {
1489 // node should be a tr element
1490 for node in node.children.borrow().iter() {
1491 if let Some(row) = self.parse_table_row(source_range.clone(), node) {
1492 body_rows.push(row);
1493 }
1494 }
1495 }
1496 }
1497 _ => {}
1498 }
1499 }
1500
1501 if !header_rows.is_empty() || !body_rows.is_empty() {
1502 Some(ParsedMarkdownTable {
1503 source_range,
1504 body: body_rows,
1505 header: header_rows,
1506 caption,
1507 })
1508 } else {
1509 None
1510 }
1511 }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517 use ParsedMarkdownListItemType::*;
1518 use core::panic;
1519 use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength};
1520 use language::{HighlightId, LanguageRegistry};
1521 use pretty_assertions::assert_eq;
1522
1523 async fn parse(input: &str) -> ParsedMarkdown {
1524 parse_markdown(input, None, None).await
1525 }
1526
1527 #[gpui::test]
1528 async fn test_headings() {
1529 let parsed = parse("# Heading one\n## Heading two\n### Heading three").await;
1530
1531 assert_eq!(
1532 parsed.children,
1533 vec![
1534 h1(text("Heading one", 2..13), 0..14),
1535 h2(text("Heading two", 17..28), 14..29),
1536 h3(text("Heading three", 33..46), 29..46),
1537 ]
1538 );
1539 }
1540
1541 #[gpui::test]
1542 async fn test_newlines_dont_new_paragraphs() {
1543 let parsed = parse("Some text **that is bolded**\n and *italicized*").await;
1544
1545 assert_eq!(
1546 parsed.children,
1547 vec![p("Some text that is bolded and italicized", 0..46)]
1548 );
1549 }
1550
1551 #[gpui::test]
1552 async fn test_heading_with_paragraph() {
1553 let parsed = parse("# Zed\nThe editor").await;
1554
1555 assert_eq!(
1556 parsed.children,
1557 vec![h1(text("Zed", 2..5), 0..6), p("The editor", 6..16),]
1558 );
1559 }
1560
1561 #[gpui::test]
1562 async fn test_double_newlines_do_new_paragraphs() {
1563 let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await;
1564
1565 assert_eq!(
1566 parsed.children,
1567 vec![
1568 p("Some text that is bolded", 0..29),
1569 p("and italicized", 31..47),
1570 ]
1571 );
1572 }
1573
1574 #[gpui::test]
1575 async fn test_bold_italic_text() {
1576 let parsed = parse("Some text **that is bolded** and *italicized*").await;
1577
1578 assert_eq!(
1579 parsed.children,
1580 vec![p("Some text that is bolded and italicized", 0..45)]
1581 );
1582 }
1583
1584 #[gpui::test]
1585 async fn test_nested_bold_strikethrough_text() {
1586 let parsed = parse("Some **bo~~strikethrough~~ld** text").await;
1587
1588 assert_eq!(parsed.children.len(), 1);
1589 assert_eq!(
1590 parsed.children[0],
1591 ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text(
1592 ParsedMarkdownText {
1593 source_range: 0..35,
1594 contents: "Some bostrikethroughld text".into(),
1595 highlights: Vec::new(),
1596 regions: Vec::new(),
1597 }
1598 )])
1599 );
1600
1601 let new_text = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
1602 text
1603 } else {
1604 panic!("Expected a paragraph");
1605 };
1606
1607 let paragraph = if let MarkdownParagraphChunk::Text(text) = &new_text[0] {
1608 text
1609 } else {
1610 panic!("Expected a text");
1611 };
1612
1613 assert_eq!(
1614 paragraph.highlights,
1615 vec![
1616 (
1617 5..7,
1618 MarkdownHighlight::Style(MarkdownHighlightStyle {
1619 weight: FontWeight::BOLD,
1620 ..Default::default()
1621 }),
1622 ),
1623 (
1624 7..20,
1625 MarkdownHighlight::Style(MarkdownHighlightStyle {
1626 weight: FontWeight::BOLD,
1627 strikethrough: true,
1628 ..Default::default()
1629 }),
1630 ),
1631 (
1632 20..22,
1633 MarkdownHighlight::Style(MarkdownHighlightStyle {
1634 weight: FontWeight::BOLD,
1635 ..Default::default()
1636 }),
1637 ),
1638 ]
1639 );
1640 }
1641
1642 #[gpui::test]
1643 async fn test_html_inline_style_elements() {
1644 let parsed =
1645 parse("<p>Some text <strong>strong text</strong> more text <b>bold text</b> more text <i>italic text</i> more text <em>emphasized text</em> more text <del>deleted text</del> more text <ins>inserted text</ins></p>").await;
1646
1647 assert_eq!(1, parsed.children.len());
1648 let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
1649 chunks
1650 } else {
1651 panic!("Expected a paragraph");
1652 };
1653
1654 assert_eq!(1, chunks.len());
1655 let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
1656 text
1657 } else {
1658 panic!("Expected a paragraph");
1659 };
1660
1661 assert_eq!(0..205, text.source_range);
1662 assert_eq!(
1663 "Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text",
1664 text.contents.as_str(),
1665 );
1666 assert_eq!(
1667 vec![
1668 (
1669 10..21,
1670 MarkdownHighlight::Style(MarkdownHighlightStyle {
1671 weight: FontWeight(700.0),
1672 ..Default::default()
1673 },),
1674 ),
1675 (
1676 32..41,
1677 MarkdownHighlight::Style(MarkdownHighlightStyle {
1678 weight: FontWeight(700.0),
1679 ..Default::default()
1680 },),
1681 ),
1682 (
1683 52..63,
1684 MarkdownHighlight::Style(MarkdownHighlightStyle {
1685 italic: true,
1686 weight: FontWeight(400.0),
1687 ..Default::default()
1688 },),
1689 ),
1690 (
1691 74..89,
1692 MarkdownHighlight::Style(MarkdownHighlightStyle {
1693 weight: FontWeight(400.0),
1694 oblique: true,
1695 ..Default::default()
1696 },),
1697 ),
1698 (
1699 100..112,
1700 MarkdownHighlight::Style(MarkdownHighlightStyle {
1701 strikethrough: true,
1702 weight: FontWeight(400.0),
1703 ..Default::default()
1704 },),
1705 ),
1706 (
1707 123..136,
1708 MarkdownHighlight::Style(MarkdownHighlightStyle {
1709 underline: true,
1710 weight: FontWeight(400.0,),
1711 ..Default::default()
1712 },),
1713 ),
1714 ],
1715 text.highlights
1716 );
1717 }
1718
1719 #[gpui::test]
1720 async fn test_html_href_element() {
1721 let parsed =
1722 parse("<p>Some text <a href=\"https://example.com\">link</a> more text</p>").await;
1723
1724 assert_eq!(1, parsed.children.len());
1725 let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
1726 chunks
1727 } else {
1728 panic!("Expected a paragraph");
1729 };
1730
1731 assert_eq!(1, chunks.len());
1732 let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
1733 text
1734 } else {
1735 panic!("Expected a paragraph");
1736 };
1737
1738 assert_eq!(0..65, text.source_range);
1739 assert_eq!("Some text link more text", text.contents.as_str(),);
1740 assert_eq!(
1741 vec![(
1742 10..14,
1743 MarkdownHighlight::Style(MarkdownHighlightStyle {
1744 link: true,
1745 ..Default::default()
1746 },),
1747 )],
1748 text.highlights
1749 );
1750 assert_eq!(
1751 vec![(
1752 10..14,
1753 ParsedRegion {
1754 code: false,
1755 link: Some(Link::Web {
1756 url: "https://example.com".into()
1757 })
1758 }
1759 )],
1760 text.regions
1761 )
1762 }
1763
1764 #[gpui::test]
1765 async fn test_text_with_inline_html() {
1766 let parsed = parse("This is a paragraph with an inline HTML <sometag>tag</sometag>.").await;
1767
1768 assert_eq!(
1769 parsed.children,
1770 vec![p("This is a paragraph with an inline HTML tag.", 0..63),],
1771 );
1772 }
1773
1774 #[gpui::test]
1775 async fn test_raw_links_detection() {
1776 let parsed = parse("Checkout this https://zed.dev link").await;
1777
1778 assert_eq!(
1779 parsed.children,
1780 vec![p("Checkout this https://zed.dev link", 0..34)]
1781 );
1782 }
1783
1784 #[gpui::test]
1785 async fn test_empty_image() {
1786 let parsed = parse("![]()").await;
1787
1788 let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
1789 text
1790 } else {
1791 panic!("Expected a paragraph");
1792 };
1793 assert_eq!(paragraph.len(), 0);
1794 }
1795
1796 #[gpui::test]
1797 async fn test_image_links_detection() {
1798 let parsed = parse("").await;
1799
1800 let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
1801 text
1802 } else {
1803 panic!("Expected a paragraph");
1804 };
1805 assert_eq!(
1806 paragraph[0],
1807 MarkdownParagraphChunk::Image(Image {
1808 source_range: 0..111,
1809 link: Link::Web {
1810 url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(),
1811 },
1812 alt_text: Some("test".into()),
1813 height: None,
1814 width: None,
1815 },)
1816 );
1817 }
1818
1819 #[gpui::test]
1820 async fn test_image_alt_text() {
1821 let parsed = parse("[](https://zed.dev)\n ").await;
1822
1823 let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
1824 text
1825 } else {
1826 panic!("Expected a paragraph");
1827 };
1828 assert_eq!(
1829 paragraph[0],
1830 MarkdownParagraphChunk::Image(Image {
1831 source_range: 0..142,
1832 link: Link::Web {
1833 url: "https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json".to_string(),
1834 },
1835 alt_text: Some("Zed".into()),
1836 height: None,
1837 width: None,
1838 },)
1839 );
1840 }
1841
1842 #[gpui::test]
1843 async fn test_image_without_alt_text() {
1844 let parsed = parse("").await;
1845
1846 let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
1847 text
1848 } else {
1849 panic!("Expected a paragraph");
1850 };
1851 assert_eq!(
1852 paragraph[0],
1853 MarkdownParagraphChunk::Image(Image {
1854 source_range: 0..31,
1855 link: Link::Web {
1856 url: "http://example.com/foo.png".to_string(),
1857 },
1858 alt_text: None,
1859 height: None,
1860 width: None,
1861 },)
1862 );
1863 }
1864
1865 #[gpui::test]
1866 async fn test_image_with_alt_text_containing_formatting() {
1867 let parsed = parse("").await;
1868
1869 let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] else {
1870 panic!("Expected a paragraph");
1871 };
1872 assert_eq!(
1873 chunks,
1874 &[MarkdownParagraphChunk::Image(Image {
1875 source_range: 0..44,
1876 link: Link::Web {
1877 url: "http://example.com/foo.png".to_string(),
1878 },
1879 alt_text: Some("foo bar baz".into()),
1880 height: None,
1881 width: None,
1882 }),],
1883 );
1884 }
1885
1886 #[gpui::test]
1887 async fn test_images_with_text_in_between() {
1888 let parsed = parse(
1889 "\nLorem Ipsum\n",
1890 )
1891 .await;
1892
1893 let chunks = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
1894 text
1895 } else {
1896 panic!("Expected a paragraph");
1897 };
1898 assert_eq!(
1899 chunks,
1900 &vec![
1901 MarkdownParagraphChunk::Image(Image {
1902 source_range: 0..81,
1903 link: Link::Web {
1904 url: "http://example.com/foo.png".to_string(),
1905 },
1906 alt_text: Some("foo".into()),
1907 height: None,
1908 width: None,
1909 }),
1910 MarkdownParagraphChunk::Text(ParsedMarkdownText {
1911 source_range: 0..81,
1912 contents: " Lorem Ipsum ".into(),
1913 highlights: Vec::new(),
1914 regions: Vec::new(),
1915 }),
1916 MarkdownParagraphChunk::Image(Image {
1917 source_range: 0..81,
1918 link: Link::Web {
1919 url: "http://example.com/bar.png".to_string(),
1920 },
1921 alt_text: Some("bar".into()),
1922 height: None,
1923 width: None,
1924 })
1925 ]
1926 );
1927 }
1928
1929 #[test]
1930 fn test_parse_html_element_dimension() {
1931 // Test percentage values
1932 assert_eq!(
1933 MarkdownParser::parse_html_element_dimension("50%"),
1934 Some(DefiniteLength::Fraction(0.5))
1935 );
1936 assert_eq!(
1937 MarkdownParser::parse_html_element_dimension("100%"),
1938 Some(DefiniteLength::Fraction(1.0))
1939 );
1940 assert_eq!(
1941 MarkdownParser::parse_html_element_dimension("25%"),
1942 Some(DefiniteLength::Fraction(0.25))
1943 );
1944 assert_eq!(
1945 MarkdownParser::parse_html_element_dimension("0%"),
1946 Some(DefiniteLength::Fraction(0.0))
1947 );
1948
1949 // Test pixel values
1950 assert_eq!(
1951 MarkdownParser::parse_html_element_dimension("100px"),
1952 Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
1953 );
1954 assert_eq!(
1955 MarkdownParser::parse_html_element_dimension("50px"),
1956 Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0))))
1957 );
1958 assert_eq!(
1959 MarkdownParser::parse_html_element_dimension("0px"),
1960 Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0))))
1961 );
1962
1963 // Test values without units (should be treated as pixels)
1964 assert_eq!(
1965 MarkdownParser::parse_html_element_dimension("100"),
1966 Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
1967 );
1968 assert_eq!(
1969 MarkdownParser::parse_html_element_dimension("42"),
1970 Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
1971 );
1972
1973 // Test invalid values
1974 assert_eq!(
1975 MarkdownParser::parse_html_element_dimension("invalid"),
1976 None
1977 );
1978 assert_eq!(MarkdownParser::parse_html_element_dimension("px"), None);
1979 assert_eq!(MarkdownParser::parse_html_element_dimension("%"), None);
1980 assert_eq!(MarkdownParser::parse_html_element_dimension(""), None);
1981 assert_eq!(MarkdownParser::parse_html_element_dimension("abc%"), None);
1982 assert_eq!(MarkdownParser::parse_html_element_dimension("abcpx"), None);
1983
1984 // Test decimal values
1985 assert_eq!(
1986 MarkdownParser::parse_html_element_dimension("50.5%"),
1987 Some(DefiniteLength::Fraction(0.505))
1988 );
1989 assert_eq!(
1990 MarkdownParser::parse_html_element_dimension("100.25px"),
1991 Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25))))
1992 );
1993 assert_eq!(
1994 MarkdownParser::parse_html_element_dimension("42.0"),
1995 Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
1996 );
1997 }
1998
1999 #[gpui::test]
2000 async fn test_html_unordered_list() {
2001 let parsed = parse(
2002 "<ul>
2003 <li>Item 1</li>
2004 <li>Item 2</li>
2005 </ul>",
2006 )
2007 .await;
2008
2009 assert_eq!(
2010 ParsedMarkdown {
2011 children: vec![
2012 nested_list_item(
2013 0..82,
2014 1,
2015 ParsedMarkdownListItemType::Unordered,
2016 vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))]
2017 ),
2018 nested_list_item(
2019 0..82,
2020 1,
2021 ParsedMarkdownListItemType::Unordered,
2022 vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))]
2023 ),
2024 ]
2025 },
2026 parsed
2027 );
2028 }
2029
2030 #[gpui::test]
2031 async fn test_html_ordered_list() {
2032 let parsed = parse(
2033 "<ol>
2034 <li>Item 1</li>
2035 <li>Item 2</li>
2036 </ol>",
2037 )
2038 .await;
2039
2040 assert_eq!(
2041 ParsedMarkdown {
2042 children: vec![
2043 nested_list_item(
2044 0..82,
2045 1,
2046 ParsedMarkdownListItemType::Ordered(1),
2047 vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))]
2048 ),
2049 nested_list_item(
2050 0..82,
2051 1,
2052 ParsedMarkdownListItemType::Ordered(2),
2053 vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))]
2054 ),
2055 ]
2056 },
2057 parsed
2058 );
2059 }
2060
2061 #[gpui::test]
2062 async fn test_html_nested_ordered_list() {
2063 let parsed = parse(
2064 "<ol>
2065 <li>Item 1</li>
2066 <li>Item 2
2067 <ol>
2068 <li>Sub-Item 1</li>
2069 <li>Sub-Item 2</li>
2070 </ol>
2071 </li>
2072 </ol>",
2073 )
2074 .await;
2075
2076 assert_eq!(
2077 ParsedMarkdown {
2078 children: vec![
2079 nested_list_item(
2080 0..216,
2081 1,
2082 ParsedMarkdownListItemType::Ordered(1),
2083 vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))]
2084 ),
2085 nested_list_item(
2086 0..216,
2087 1,
2088 ParsedMarkdownListItemType::Ordered(2),
2089 vec![
2090 ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)),
2091 nested_list_item(
2092 0..216,
2093 2,
2094 ParsedMarkdownListItemType::Ordered(1),
2095 vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))]
2096 ),
2097 nested_list_item(
2098 0..216,
2099 2,
2100 ParsedMarkdownListItemType::Ordered(2),
2101 vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))]
2102 ),
2103 ]
2104 ),
2105 ]
2106 },
2107 parsed
2108 );
2109 }
2110
2111 #[gpui::test]
2112 async fn test_html_nested_unordered_list() {
2113 let parsed = parse(
2114 "<ul>
2115 <li>Item 1</li>
2116 <li>Item 2
2117 <ul>
2118 <li>Sub-Item 1</li>
2119 <li>Sub-Item 2</li>
2120 </ul>
2121 </li>
2122 </ul>",
2123 )
2124 .await;
2125
2126 assert_eq!(
2127 ParsedMarkdown {
2128 children: vec![
2129 nested_list_item(
2130 0..216,
2131 1,
2132 ParsedMarkdownListItemType::Unordered,
2133 vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))]
2134 ),
2135 nested_list_item(
2136 0..216,
2137 1,
2138 ParsedMarkdownListItemType::Unordered,
2139 vec![
2140 ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)),
2141 nested_list_item(
2142 0..216,
2143 2,
2144 ParsedMarkdownListItemType::Unordered,
2145 vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))]
2146 ),
2147 nested_list_item(
2148 0..216,
2149 2,
2150 ParsedMarkdownListItemType::Unordered,
2151 vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))]
2152 ),
2153 ]
2154 ),
2155 ]
2156 },
2157 parsed
2158 );
2159 }
2160
2161 #[gpui::test]
2162 async fn test_inline_html_image_tag() {
2163 let parsed =
2164 parse("<p>Some text<img src=\"http://example.com/foo.png\" /> some more text</p>")
2165 .await;
2166
2167 assert_eq!(
2168 ParsedMarkdown {
2169 children: vec![ParsedMarkdownElement::Paragraph(vec![
2170 MarkdownParagraphChunk::Text(ParsedMarkdownText {
2171 source_range: 0..71,
2172 contents: "Some text".into(),
2173 highlights: Default::default(),
2174 regions: Default::default()
2175 }),
2176 MarkdownParagraphChunk::Image(Image {
2177 source_range: 0..71,
2178 link: Link::Web {
2179 url: "http://example.com/foo.png".to_string(),
2180 },
2181 alt_text: None,
2182 height: None,
2183 width: None,
2184 }),
2185 MarkdownParagraphChunk::Text(ParsedMarkdownText {
2186 source_range: 0..71,
2187 contents: " some more text".into(),
2188 highlights: Default::default(),
2189 regions: Default::default()
2190 }),
2191 ])]
2192 },
2193 parsed
2194 );
2195 }
2196
2197 #[gpui::test]
2198 async fn test_html_block_quote() {
2199 let parsed = parse(
2200 "<blockquote>
2201 <p>some description</p>
2202 </blockquote>",
2203 )
2204 .await;
2205
2206 assert_eq!(
2207 ParsedMarkdown {
2208 children: vec![block_quote(
2209 vec![ParsedMarkdownElement::Paragraph(text(
2210 "some description",
2211 0..78
2212 ))],
2213 0..78,
2214 )]
2215 },
2216 parsed
2217 );
2218 }
2219
2220 #[gpui::test]
2221 async fn test_html_nested_block_quote() {
2222 let parsed = parse(
2223 "<blockquote>
2224 <p>some description</p>
2225 <blockquote>
2226 <p>second description</p>
2227 </blockquote>
2228 </blockquote>",
2229 )
2230 .await;
2231
2232 assert_eq!(
2233 ParsedMarkdown {
2234 children: vec![block_quote(
2235 vec![
2236 ParsedMarkdownElement::Paragraph(text("some description", 0..179)),
2237 block_quote(
2238 vec![ParsedMarkdownElement::Paragraph(text(
2239 "second description",
2240 0..179
2241 ))],
2242 0..179,
2243 )
2244 ],
2245 0..179,
2246 )]
2247 },
2248 parsed
2249 );
2250 }
2251
2252 #[gpui::test]
2253 async fn test_html_table() {
2254 let parsed = parse(
2255 "<table>
2256 <thead>
2257 <tr>
2258 <th>Id</th>
2259 <th>Name</th>
2260 </tr>
2261 </thead>
2262 <tbody>
2263 <tr>
2264 <td>1</td>
2265 <td>Chris</td>
2266 </tr>
2267 <tr>
2268 <td>2</td>
2269 <td>Dennis</td>
2270 </tr>
2271 </tbody>
2272 </table>",
2273 )
2274 .await;
2275
2276 assert_eq!(
2277 ParsedMarkdown {
2278 children: vec![ParsedMarkdownElement::Table(table(
2279 0..366,
2280 None,
2281 vec![row(vec![
2282 column(
2283 1,
2284 1,
2285 true,
2286 text("Id", 0..366),
2287 ParsedMarkdownTableAlignment::Center
2288 ),
2289 column(
2290 1,
2291 1,
2292 true,
2293 text("Name ", 0..366),
2294 ParsedMarkdownTableAlignment::Center
2295 )
2296 ])],
2297 vec![
2298 row(vec![
2299 column(
2300 1,
2301 1,
2302 false,
2303 text("1", 0..366),
2304 ParsedMarkdownTableAlignment::None
2305 ),
2306 column(
2307 1,
2308 1,
2309 false,
2310 text("Chris", 0..366),
2311 ParsedMarkdownTableAlignment::None
2312 )
2313 ]),
2314 row(vec![
2315 column(
2316 1,
2317 1,
2318 false,
2319 text("2", 0..366),
2320 ParsedMarkdownTableAlignment::None
2321 ),
2322 column(
2323 1,
2324 1,
2325 false,
2326 text("Dennis", 0..366),
2327 ParsedMarkdownTableAlignment::None
2328 )
2329 ]),
2330 ],
2331 ))],
2332 },
2333 parsed
2334 );
2335 }
2336
2337 #[gpui::test]
2338 async fn test_html_table_with_caption() {
2339 let parsed = parse(
2340 "<table>
2341 <caption>My Table</caption>
2342 <tbody>
2343 <tr>
2344 <td>1</td>
2345 <td>Chris</td>
2346 </tr>
2347 <tr>
2348 <td>2</td>
2349 <td>Dennis</td>
2350 </tr>
2351 </tbody>
2352 </table>",
2353 )
2354 .await;
2355
2356 assert_eq!(
2357 ParsedMarkdown {
2358 children: vec![ParsedMarkdownElement::Table(table(
2359 0..280,
2360 Some(vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
2361 source_range: 0..280,
2362 contents: "My Table".into(),
2363 highlights: Default::default(),
2364 regions: Default::default()
2365 })]),
2366 vec![],
2367 vec![
2368 row(vec![
2369 column(
2370 1,
2371 1,
2372 false,
2373 text("1", 0..280),
2374 ParsedMarkdownTableAlignment::None
2375 ),
2376 column(
2377 1,
2378 1,
2379 false,
2380 text("Chris", 0..280),
2381 ParsedMarkdownTableAlignment::None
2382 )
2383 ]),
2384 row(vec![
2385 column(
2386 1,
2387 1,
2388 false,
2389 text("2", 0..280),
2390 ParsedMarkdownTableAlignment::None
2391 ),
2392 column(
2393 1,
2394 1,
2395 false,
2396 text("Dennis", 0..280),
2397 ParsedMarkdownTableAlignment::None
2398 )
2399 ]),
2400 ],
2401 ))],
2402 },
2403 parsed
2404 );
2405 }
2406
2407 #[gpui::test]
2408 async fn test_html_table_without_headings() {
2409 let parsed = parse(
2410 "<table>
2411 <tbody>
2412 <tr>
2413 <td>1</td>
2414 <td>Chris</td>
2415 </tr>
2416 <tr>
2417 <td>2</td>
2418 <td>Dennis</td>
2419 </tr>
2420 </tbody>
2421 </table>",
2422 )
2423 .await;
2424
2425 assert_eq!(
2426 ParsedMarkdown {
2427 children: vec![ParsedMarkdownElement::Table(table(
2428 0..240,
2429 None,
2430 vec![],
2431 vec![
2432 row(vec![
2433 column(
2434 1,
2435 1,
2436 false,
2437 text("1", 0..240),
2438 ParsedMarkdownTableAlignment::None
2439 ),
2440 column(
2441 1,
2442 1,
2443 false,
2444 text("Chris", 0..240),
2445 ParsedMarkdownTableAlignment::None
2446 )
2447 ]),
2448 row(vec![
2449 column(
2450 1,
2451 1,
2452 false,
2453 text("2", 0..240),
2454 ParsedMarkdownTableAlignment::None
2455 ),
2456 column(
2457 1,
2458 1,
2459 false,
2460 text("Dennis", 0..240),
2461 ParsedMarkdownTableAlignment::None
2462 )
2463 ]),
2464 ],
2465 ))],
2466 },
2467 parsed
2468 );
2469 }
2470
2471 #[gpui::test]
2472 async fn test_html_table_without_body() {
2473 let parsed = parse(
2474 "<table>
2475 <thead>
2476 <tr>
2477 <th>Id</th>
2478 <th>Name</th>
2479 </tr>
2480 </thead>
2481 </table>",
2482 )
2483 .await;
2484
2485 assert_eq!(
2486 ParsedMarkdown {
2487 children: vec![ParsedMarkdownElement::Table(table(
2488 0..150,
2489 None,
2490 vec![row(vec![
2491 column(
2492 1,
2493 1,
2494 true,
2495 text("Id", 0..150),
2496 ParsedMarkdownTableAlignment::Center
2497 ),
2498 column(
2499 1,
2500 1,
2501 true,
2502 text("Name", 0..150),
2503 ParsedMarkdownTableAlignment::Center
2504 )
2505 ])],
2506 vec![],
2507 ))],
2508 },
2509 parsed
2510 );
2511 }
2512
2513 #[gpui::test]
2514 async fn test_html_heading_tags() {
2515 let parsed = parse("<h1>Heading</h1><h2>Heading</h2><h3>Heading</h3><h4>Heading</h4><h5>Heading</h5><h6>Heading</h6>").await;
2516
2517 assert_eq!(
2518 ParsedMarkdown {
2519 children: vec![
2520 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
2521 level: HeadingLevel::H1,
2522 source_range: 0..96,
2523 contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
2524 source_range: 0..96,
2525 contents: "Heading".into(),
2526 highlights: Vec::default(),
2527 regions: Vec::default()
2528 })],
2529 }),
2530 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
2531 level: HeadingLevel::H2,
2532 source_range: 0..96,
2533 contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
2534 source_range: 0..96,
2535 contents: "Heading".into(),
2536 highlights: Vec::default(),
2537 regions: Vec::default()
2538 })],
2539 }),
2540 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
2541 level: HeadingLevel::H3,
2542 source_range: 0..96,
2543 contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
2544 source_range: 0..96,
2545 contents: "Heading".into(),
2546 highlights: Vec::default(),
2547 regions: Vec::default()
2548 })],
2549 }),
2550 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
2551 level: HeadingLevel::H4,
2552 source_range: 0..96,
2553 contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
2554 source_range: 0..96,
2555 contents: "Heading".into(),
2556 highlights: Vec::default(),
2557 regions: Vec::default()
2558 })],
2559 }),
2560 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
2561 level: HeadingLevel::H5,
2562 source_range: 0..96,
2563 contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
2564 source_range: 0..96,
2565 contents: "Heading".into(),
2566 highlights: Vec::default(),
2567 regions: Vec::default()
2568 })],
2569 }),
2570 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
2571 level: HeadingLevel::H6,
2572 source_range: 0..96,
2573 contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
2574 source_range: 0..96,
2575 contents: "Heading".into(),
2576 highlights: Vec::default(),
2577 regions: Vec::default()
2578 })],
2579 }),
2580 ],
2581 },
2582 parsed
2583 );
2584 }
2585
2586 #[gpui::test]
2587 async fn test_html_image_tag() {
2588 let parsed = parse("<img src=\"http://example.com/foo.png\" />").await;
2589
2590 assert_eq!(
2591 ParsedMarkdown {
2592 children: vec![ParsedMarkdownElement::Image(Image {
2593 source_range: 0..40,
2594 link: Link::Web {
2595 url: "http://example.com/foo.png".to_string(),
2596 },
2597 alt_text: None,
2598 height: None,
2599 width: None,
2600 })]
2601 },
2602 parsed
2603 );
2604 }
2605
2606 #[gpui::test]
2607 async fn test_html_image_tag_with_alt_text() {
2608 let parsed = parse("<img src=\"http://example.com/foo.png\" alt=\"Foo\" />").await;
2609
2610 assert_eq!(
2611 ParsedMarkdown {
2612 children: vec![ParsedMarkdownElement::Image(Image {
2613 source_range: 0..50,
2614 link: Link::Web {
2615 url: "http://example.com/foo.png".to_string(),
2616 },
2617 alt_text: Some("Foo".into()),
2618 height: None,
2619 width: None,
2620 })]
2621 },
2622 parsed
2623 );
2624 }
2625
2626 #[gpui::test]
2627 async fn test_html_image_tag_with_height_and_width() {
2628 let parsed =
2629 parse("<img src=\"http://example.com/foo.png\" height=\"100\" width=\"200\" />").await;
2630
2631 assert_eq!(
2632 ParsedMarkdown {
2633 children: vec![ParsedMarkdownElement::Image(Image {
2634 source_range: 0..65,
2635 link: Link::Web {
2636 url: "http://example.com/foo.png".to_string(),
2637 },
2638 alt_text: None,
2639 height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
2640 width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
2641 })]
2642 },
2643 parsed
2644 );
2645 }
2646
2647 #[gpui::test]
2648 async fn test_html_image_style_tag_with_height_and_width() {
2649 let parsed = parse(
2650 "<img src=\"http://example.com/foo.png\" style=\"height:100px; width:200px;\" />",
2651 )
2652 .await;
2653
2654 assert_eq!(
2655 ParsedMarkdown {
2656 children: vec![ParsedMarkdownElement::Image(Image {
2657 source_range: 0..75,
2658 link: Link::Web {
2659 url: "http://example.com/foo.png".to_string(),
2660 },
2661 alt_text: None,
2662 height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
2663 width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
2664 })]
2665 },
2666 parsed
2667 );
2668 }
2669
2670 #[gpui::test]
2671 async fn test_header_only_table() {
2672 let markdown = "\
2673| Header 1 | Header 2 |
2674|----------|----------|
2675
2676Some other content
2677";
2678
2679 let expected_table = table(
2680 0..48,
2681 None,
2682 vec![row(vec![
2683 column(
2684 1,
2685 1,
2686 true,
2687 text("Header 1", 1..11),
2688 ParsedMarkdownTableAlignment::None,
2689 ),
2690 column(
2691 1,
2692 1,
2693 true,
2694 text("Header 2", 12..22),
2695 ParsedMarkdownTableAlignment::None,
2696 ),
2697 ])],
2698 vec![],
2699 );
2700
2701 assert_eq!(
2702 parse(markdown).await.children[0],
2703 ParsedMarkdownElement::Table(expected_table)
2704 );
2705 }
2706
2707 #[gpui::test]
2708 async fn test_basic_table() {
2709 let markdown = "\
2710| Header 1 | Header 2 |
2711|----------|----------|
2712| Cell 1 | Cell 2 |
2713| Cell 3 | Cell 4 |";
2714
2715 let expected_table = table(
2716 0..95,
2717 None,
2718 vec![row(vec![
2719 column(
2720 1,
2721 1,
2722 true,
2723 text("Header 1", 1..11),
2724 ParsedMarkdownTableAlignment::None,
2725 ),
2726 column(
2727 1,
2728 1,
2729 true,
2730 text("Header 2", 12..22),
2731 ParsedMarkdownTableAlignment::None,
2732 ),
2733 ])],
2734 vec![
2735 row(vec![
2736 column(
2737 1,
2738 1,
2739 false,
2740 text("Cell 1", 49..59),
2741 ParsedMarkdownTableAlignment::None,
2742 ),
2743 column(
2744 1,
2745 1,
2746 false,
2747 text("Cell 2", 60..70),
2748 ParsedMarkdownTableAlignment::None,
2749 ),
2750 ]),
2751 row(vec![
2752 column(
2753 1,
2754 1,
2755 false,
2756 text("Cell 3", 73..83),
2757 ParsedMarkdownTableAlignment::None,
2758 ),
2759 column(
2760 1,
2761 1,
2762 false,
2763 text("Cell 4", 84..94),
2764 ParsedMarkdownTableAlignment::None,
2765 ),
2766 ]),
2767 ],
2768 );
2769
2770 assert_eq!(
2771 parse(markdown).await.children[0],
2772 ParsedMarkdownElement::Table(expected_table)
2773 );
2774 }
2775
2776 #[gpui::test]
2777 async fn test_list_basic() {
2778 let parsed = parse(
2779 "\
2780* Item 1
2781* Item 2
2782* Item 3
2783",
2784 )
2785 .await;
2786
2787 assert_eq!(
2788 parsed.children,
2789 vec![
2790 list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]),
2791 list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]),
2792 list_item(18..26, 1, Unordered, vec![p("Item 3", 20..26)]),
2793 ],
2794 );
2795 }
2796
2797 #[gpui::test]
2798 async fn test_list_with_tasks() {
2799 let parsed = parse(
2800 "\
2801- [ ] TODO
2802- [x] Checked
2803",
2804 )
2805 .await;
2806
2807 assert_eq!(
2808 parsed.children,
2809 vec![
2810 list_item(0..10, 1, Task(false, 2..5), vec![p("TODO", 6..10)]),
2811 list_item(11..24, 1, Task(true, 13..16), vec![p("Checked", 17..24)]),
2812 ],
2813 );
2814 }
2815
2816 #[gpui::test]
2817 async fn test_list_with_indented_task() {
2818 let parsed = parse(
2819 "\
2820- [ ] TODO
2821 - [x] Checked
2822 - Unordered
2823 1. Number 1
2824 1. Number 2
28251. Number A
2826",
2827 )
2828 .await;
2829
2830 assert_eq!(
2831 parsed.children,
2832 vec![
2833 list_item(0..12, 1, Task(false, 2..5), vec![p("TODO", 6..10)]),
2834 list_item(13..26, 2, Task(true, 15..18), vec![p("Checked", 19..26)]),
2835 list_item(29..40, 2, Unordered, vec![p("Unordered", 31..40)]),
2836 list_item(43..54, 2, Ordered(1), vec![p("Number 1", 46..54)]),
2837 list_item(57..68, 2, Ordered(2), vec![p("Number 2", 60..68)]),
2838 list_item(69..80, 1, Ordered(1), vec![p("Number A", 72..80)]),
2839 ],
2840 );
2841 }
2842
2843 #[gpui::test]
2844 async fn test_list_with_linebreak_is_handled_correctly() {
2845 let parsed = parse(
2846 "\
2847- [ ] Task 1
2848
2849- [x] Task 2
2850",
2851 )
2852 .await;
2853
2854 assert_eq!(
2855 parsed.children,
2856 vec![
2857 list_item(0..13, 1, Task(false, 2..5), vec![p("Task 1", 6..12)]),
2858 list_item(14..26, 1, Task(true, 16..19), vec![p("Task 2", 20..26)]),
2859 ],
2860 );
2861 }
2862
2863 #[gpui::test]
2864 async fn test_list_nested() {
2865 let parsed = parse(
2866 "\
2867* Item 1
2868* Item 2
2869* Item 3
2870
28711. Hello
28721. Two
2873 1. Three
28742. Four
28753. Five
2876
2877* First
2878 1. Hello
2879 1. Goodbyte
2880 - Inner
2881 - Inner
2882 2. Goodbyte
2883 - Next item empty
2884 -
2885* Last
2886",
2887 )
2888 .await;
2889
2890 assert_eq!(
2891 parsed.children,
2892 vec![
2893 list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]),
2894 list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]),
2895 list_item(18..27, 1, Unordered, vec![p("Item 3", 20..26)]),
2896 list_item(28..36, 1, Ordered(1), vec![p("Hello", 31..36)]),
2897 list_item(37..46, 1, Ordered(2), vec![p("Two", 40..43),]),
2898 list_item(47..55, 2, Ordered(1), vec![p("Three", 50..55)]),
2899 list_item(56..63, 1, Ordered(3), vec![p("Four", 59..63)]),
2900 list_item(64..72, 1, Ordered(4), vec![p("Five", 67..71)]),
2901 list_item(73..82, 1, Unordered, vec![p("First", 75..80)]),
2902 list_item(83..96, 2, Ordered(1), vec![p("Hello", 86..91)]),
2903 list_item(97..116, 3, Ordered(1), vec![p("Goodbyte", 100..108)]),
2904 list_item(117..124, 4, Unordered, vec![p("Inner", 119..124)]),
2905 list_item(133..140, 4, Unordered, vec![p("Inner", 135..140)]),
2906 list_item(143..159, 2, Ordered(2), vec![p("Goodbyte", 146..154)]),
2907 list_item(160..180, 3, Unordered, vec![p("Next item empty", 165..180)]),
2908 list_item(186..190, 3, Unordered, vec![]),
2909 list_item(191..197, 1, Unordered, vec![p("Last", 193..197)]),
2910 ]
2911 );
2912 }
2913
2914 #[gpui::test]
2915 async fn test_list_with_nested_content() {
2916 let parsed = parse(
2917 "\
2918* This is a list item with two paragraphs.
2919
2920 This is the second paragraph in the list item.
2921",
2922 )
2923 .await;
2924
2925 assert_eq!(
2926 parsed.children,
2927 vec![list_item(
2928 0..96,
2929 1,
2930 Unordered,
2931 vec![
2932 p("This is a list item with two paragraphs.", 4..44),
2933 p("This is the second paragraph in the list item.", 50..97)
2934 ],
2935 ),],
2936 );
2937 }
2938
2939 #[gpui::test]
2940 async fn test_list_item_with_inline_html() {
2941 let parsed = parse(
2942 "\
2943* This is a list item with an inline HTML <sometag>tag</sometag>.
2944",
2945 )
2946 .await;
2947
2948 assert_eq!(
2949 parsed.children,
2950 vec![list_item(
2951 0..67,
2952 1,
2953 Unordered,
2954 vec![p("This is a list item with an inline HTML tag.", 4..44),],
2955 ),],
2956 );
2957 }
2958
2959 #[gpui::test]
2960 async fn test_nested_list_with_paragraph_inside() {
2961 let parsed = parse(
2962 "\
29631. a
2964 1. b
2965 1. c
2966
2967 text
2968
2969 1. d
2970",
2971 )
2972 .await;
2973
2974 assert_eq!(
2975 parsed.children,
2976 vec![
2977 list_item(0..7, 1, Ordered(1), vec![p("a", 3..4)],),
2978 list_item(8..20, 2, Ordered(1), vec![p("b", 12..13),],),
2979 list_item(21..27, 3, Ordered(1), vec![p("c", 25..26),],),
2980 p("text", 32..37),
2981 list_item(41..46, 2, Ordered(1), vec![p("d", 45..46),],),
2982 ],
2983 );
2984 }
2985
2986 #[gpui::test]
2987 async fn test_list_with_leading_text() {
2988 let parsed = parse(
2989 "\
2990* `code`
2991* **bold**
2992* [link](https://example.com)
2993",
2994 )
2995 .await;
2996
2997 assert_eq!(
2998 parsed.children,
2999 vec![
3000 list_item(0..8, 1, Unordered, vec![p("code", 2..8)]),
3001 list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]),
3002 list_item(20..49, 1, Unordered, vec![p("link", 22..49)],),
3003 ],
3004 );
3005 }
3006
3007 #[gpui::test]
3008 async fn test_simple_block_quote() {
3009 let parsed = parse("> Simple block quote with **styled text**").await;
3010
3011 assert_eq!(
3012 parsed.children,
3013 vec![block_quote(
3014 vec![p("Simple block quote with styled text", 2..41)],
3015 0..41
3016 )]
3017 );
3018 }
3019
3020 #[gpui::test]
3021 async fn test_simple_block_quote_with_multiple_lines() {
3022 let parsed = parse(
3023 "\
3024> # Heading
3025> More
3026> text
3027>
3028> More text
3029",
3030 )
3031 .await;
3032
3033 assert_eq!(
3034 parsed.children,
3035 vec![block_quote(
3036 vec![
3037 h1(text("Heading", 4..11), 2..12),
3038 p("More text", 14..26),
3039 p("More text", 30..40)
3040 ],
3041 0..40
3042 )]
3043 );
3044 }
3045
3046 #[gpui::test]
3047 async fn test_nested_block_quote() {
3048 let parsed = parse(
3049 "\
3050> A
3051>
3052> > # B
3053>
3054> C
3055
3056More text
3057",
3058 )
3059 .await;
3060
3061 assert_eq!(
3062 parsed.children,
3063 vec![
3064 block_quote(
3065 vec![
3066 p("A", 2..4),
3067 block_quote(vec![h1(text("B", 12..13), 10..14)], 8..14),
3068 p("C", 18..20)
3069 ],
3070 0..20
3071 ),
3072 p("More text", 21..31)
3073 ]
3074 );
3075 }
3076
3077 #[gpui::test]
3078 async fn test_dollar_signs_are_plain_text() {
3079 // Dollar signs should be preserved as plain text, not treated as math delimiters.
3080 // Regression test for https://github.com/zed-industries/zed/issues/50170
3081 let parsed = parse("$100$ per unit").await;
3082 assert_eq!(parsed.children, vec![p("$100$ per unit", 0..14)]);
3083 }
3084
3085 #[gpui::test]
3086 async fn test_dollar_signs_in_list_items() {
3087 let parsed = parse("- $18,000 budget\n- $20,000 budget\n").await;
3088 assert_eq!(
3089 parsed.children,
3090 vec![
3091 list_item(0..16, 1, Unordered, vec![p("$18,000 budget", 2..16)]),
3092 list_item(17..33, 1, Unordered, vec![p("$20,000 budget", 19..33)]),
3093 ]
3094 );
3095 }
3096
3097 #[gpui::test]
3098 async fn test_code_block() {
3099 let parsed = parse(
3100 "\
3101```
3102fn main() {
3103 return 0;
3104}
3105```
3106",
3107 )
3108 .await;
3109
3110 assert_eq!(
3111 parsed.children,
3112 vec![code_block(
3113 None,
3114 "fn main() {\n return 0;\n}",
3115 0..35,
3116 None
3117 )]
3118 );
3119 }
3120
3121 #[gpui::test]
3122 async fn test_code_block_with_language(executor: BackgroundExecutor) {
3123 let language_registry = Arc::new(LanguageRegistry::test(executor.clone()));
3124 language_registry.add(language::rust_lang());
3125
3126 let parsed = parse_markdown(
3127 "\
3128```rust
3129fn main() {
3130 return 0;
3131}
3132```
3133",
3134 None,
3135 Some(language_registry),
3136 )
3137 .await;
3138
3139 assert_eq!(
3140 parsed.children,
3141 vec![code_block(
3142 Some("rust".to_string()),
3143 "fn main() {\n return 0;\n}",
3144 0..39,
3145 Some(vec![])
3146 )]
3147 );
3148 }
3149
3150 fn h1(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
3151 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
3152 source_range,
3153 level: HeadingLevel::H1,
3154 contents,
3155 })
3156 }
3157
3158 fn h2(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
3159 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
3160 source_range,
3161 level: HeadingLevel::H2,
3162 contents,
3163 })
3164 }
3165
3166 fn h3(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
3167 ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
3168 source_range,
3169 level: HeadingLevel::H3,
3170 contents,
3171 })
3172 }
3173
3174 fn p(contents: &str, source_range: Range<usize>) -> ParsedMarkdownElement {
3175 ParsedMarkdownElement::Paragraph(text(contents, source_range))
3176 }
3177
3178 fn text(contents: &str, source_range: Range<usize>) -> MarkdownParagraph {
3179 vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
3180 highlights: Vec::new(),
3181 regions: Vec::new(),
3182 source_range,
3183 contents: contents.to_string().into(),
3184 })]
3185 }
3186
3187 fn block_quote(
3188 children: Vec<ParsedMarkdownElement>,
3189 source_range: Range<usize>,
3190 ) -> ParsedMarkdownElement {
3191 ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote {
3192 source_range,
3193 children,
3194 })
3195 }
3196
3197 fn code_block(
3198 language: Option<String>,
3199 code: &str,
3200 source_range: Range<usize>,
3201 highlights: Option<Vec<(Range<usize>, HighlightId)>>,
3202 ) -> ParsedMarkdownElement {
3203 ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock {
3204 source_range,
3205 language,
3206 contents: code.to_string().into(),
3207 highlights,
3208 })
3209 }
3210
3211 fn list_item(
3212 source_range: Range<usize>,
3213 depth: u16,
3214 item_type: ParsedMarkdownListItemType,
3215 content: Vec<ParsedMarkdownElement>,
3216 ) -> ParsedMarkdownElement {
3217 ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
3218 source_range,
3219 item_type,
3220 depth,
3221 content,
3222 nested: false,
3223 })
3224 }
3225
3226 fn nested_list_item(
3227 source_range: Range<usize>,
3228 depth: u16,
3229 item_type: ParsedMarkdownListItemType,
3230 content: Vec<ParsedMarkdownElement>,
3231 ) -> ParsedMarkdownElement {
3232 ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
3233 source_range,
3234 item_type,
3235 depth,
3236 content,
3237 nested: true,
3238 })
3239 }
3240
3241 fn table(
3242 source_range: Range<usize>,
3243 caption: Option<MarkdownParagraph>,
3244 header: Vec<ParsedMarkdownTableRow>,
3245 body: Vec<ParsedMarkdownTableRow>,
3246 ) -> ParsedMarkdownTable {
3247 ParsedMarkdownTable {
3248 source_range,
3249 header,
3250 body,
3251 caption,
3252 }
3253 }
3254
3255 fn row(columns: Vec<ParsedMarkdownTableColumn>) -> ParsedMarkdownTableRow {
3256 ParsedMarkdownTableRow { columns }
3257 }
3258
3259 fn column(
3260 col_span: usize,
3261 row_span: usize,
3262 is_header: bool,
3263 children: MarkdownParagraph,
3264 alignment: ParsedMarkdownTableAlignment,
3265 ) -> ParsedMarkdownTableColumn {
3266 ParsedMarkdownTableColumn {
3267 col_span,
3268 row_span,
3269 is_header,
3270 children,
3271 alignment,
3272 }
3273 }
3274
3275 impl PartialEq for ParsedMarkdownTable {
3276 fn eq(&self, other: &Self) -> bool {
3277 self.source_range == other.source_range
3278 && self.header == other.header
3279 && self.body == other.body
3280 }
3281 }
3282
3283 impl PartialEq for ParsedMarkdownText {
3284 fn eq(&self, other: &Self) -> bool {
3285 self.source_range == other.source_range && self.contents == other.contents
3286 }
3287 }
3288}