diff --git a/Cargo.lock b/Cargo.lock index 16f8dd76ab23bf274b1e6b79515fa8060f2a646f..33645135abda30a991f7645338fa84bd1618d574 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10265,6 +10265,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" name = "markdown" version = "0.1.0" dependencies = [ + "anyhow", "assets", "base64 0.22.1", "collections", @@ -10273,13 +10274,17 @@ dependencies = [ "futures 0.3.31", "gpui", "gpui_platform", + "html5ever 0.27.0", "language", "languages", "linkify", "log", + "markup5ever_rcdom", + "mermaid-rs-renderer", "node_runtime", "pulldown-cmark 0.13.0", "settings", + "stacksafe", "sum_tree", "theme", "ui", @@ -10291,21 +10296,13 @@ name = "markdown_preview" version = "0.1.0" dependencies = [ "anyhow", - "async-recursion", - "collections", "editor", "gpui", - "html5ever 0.27.0", "language", - "linkify", "log", "markdown", - "markup5ever_rcdom", - "mermaid-rs-renderer", - "pretty_assertions", - "pulldown-cmark 0.13.0", "settings", - "stacksafe", + "tempfile", "theme", "ui", "urlencoding", diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index c923d3f704488a5364707486d25181188f74f166..18bba1fc64f193cf17be1a728fc533a6596296b1 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -19,15 +19,20 @@ test-support = [ ] [dependencies] +anyhow.workspace = true base64.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true +html5ever.workspace = true language.workspace = true linkify.workspace = true log.workspace = true +markup5ever_rcdom.workspace = true +mermaid-rs-renderer.workspace = true pulldown-cmark.workspace = true settings.workspace = true +stacksafe.workspace = true sum_tree.workspace = true theme.workspace = true ui.workspace = true diff --git a/crates/markdown/src/html.rs b/crates/markdown/src/html.rs new file mode 100644 index 0000000000000000000000000000000000000000..cf37f6138cd49733b5ca6f093ced9a00481f4edb --- /dev/null +++ b/crates/markdown/src/html.rs @@ -0,0 +1,3 @@ +mod html_minifier; +pub(crate) mod html_parser; +mod html_rendering; diff --git a/crates/markdown_preview/src/markdown_minifier.rs b/crates/markdown/src/html/html_minifier.rs similarity index 100% rename from crates/markdown_preview/src/markdown_minifier.rs rename to crates/markdown/src/html/html_minifier.rs diff --git a/crates/markdown/src/html/html_parser.rs b/crates/markdown/src/html/html_parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..20338ec2abef2314b7cd6ca91e45ee05be909745 --- /dev/null +++ b/crates/markdown/src/html/html_parser.rs @@ -0,0 +1,883 @@ +use std::{cell::RefCell, collections::HashMap, mem, ops::Range}; + +use gpui::{DefiniteLength, FontWeight, SharedString, px, relative}; +use html5ever::{ + Attribute, LocalName, ParseOpts, local_name, parse_document, tendril::TendrilSink, +}; +use markup5ever_rcdom::{Node, NodeData, RcDom}; +use pulldown_cmark::{Alignment, HeadingLevel}; +use stacksafe::stacksafe; + +use crate::html::html_minifier::{Minifier, MinifierOptions}; + +#[derive(Debug, Clone, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlBlock { + pub source_range: Range, + pub children: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum ParsedHtmlElement { + Heading(ParsedHtmlHeading), + List(ParsedHtmlList), + Table(ParsedHtmlTable), + BlockQuote(ParsedHtmlBlockQuote), + Paragraph(HtmlParagraph), + Image(HtmlImage), +} + +impl ParsedHtmlElement { + pub fn source_range(&self) -> Option> { + Some(match self { + Self::Heading(heading) => heading.source_range.clone(), + Self::List(list) => list.source_range.clone(), + Self::Table(table) => table.source_range.clone(), + Self::BlockQuote(block_quote) => block_quote.source_range.clone(), + Self::Paragraph(text) => match text.first()? { + HtmlParagraphChunk::Text(text) => text.source_range.clone(), + HtmlParagraphChunk::Image(image) => image.source_range.clone(), + }, + Self::Image(image) => image.source_range.clone(), + }) + } +} + +pub(crate) type HtmlParagraph = Vec; + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum HtmlParagraphChunk { + Text(ParsedHtmlText), + Image(HtmlImage), +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlList { + pub source_range: Range, + pub depth: u16, + pub ordered: bool, + pub items: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlListItem { + pub source_range: Range, + pub item_type: ParsedHtmlListItemType, + pub content: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum ParsedHtmlListItemType { + Ordered(u64), + Unordered, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlHeading { + pub source_range: Range, + pub level: HeadingLevel, + pub contents: HtmlParagraph, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTable { + pub source_range: Range, + pub header: Vec, + pub body: Vec, + pub caption: Option, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTableColumn { + pub col_span: usize, + pub row_span: usize, + pub is_header: bool, + pub children: HtmlParagraph, + pub alignment: Alignment, +} + +#[derive(Debug, Clone, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTableRow { + pub columns: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlBlockQuote { + pub source_range: Range, + pub children: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlText { + pub source_range: Range, + pub contents: SharedString, + pub highlights: Vec<(Range, HtmlHighlightStyle)>, + pub links: Vec<(Range, SharedString)>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct HtmlHighlightStyle { + pub italic: bool, + pub underline: bool, + pub strikethrough: bool, + pub weight: FontWeight, + pub link: bool, + pub oblique: bool, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct HtmlImage { + pub dest_url: SharedString, + pub source_range: Range, + pub alt_text: Option, + pub width: Option, + pub height: Option, +} + +impl HtmlImage { + fn new(dest_url: String, source_range: Range) -> Self { + Self { + dest_url: dest_url.into(), + source_range, + alt_text: None, + width: None, + height: None, + } + } + + fn set_alt_text(&mut self, alt_text: SharedString) { + self.alt_text = Some(alt_text); + } + + fn set_width(&mut self, width: DefiniteLength) { + self.width = Some(width); + } + + fn set_height(&mut self, height: DefiniteLength) { + self.height = Some(height); + } +} + +#[derive(Debug)] +struct ParseHtmlNodeContext { + list_item_depth: u16, +} + +impl Default for ParseHtmlNodeContext { + fn default() -> Self { + Self { list_item_depth: 1 } + } +} + +pub(crate) fn parse_html_block( + source: &str, + source_range: Range, +) -> Option { + let bytes = cleanup_html(source); + let mut cursor = std::io::Cursor::new(bytes); + let dom = parse_document(RcDom::default(), ParseOpts::default()) + .from_utf8() + .read_from(&mut cursor) + .ok()?; + + let mut children = Vec::new(); + parse_html_node( + source_range.clone(), + &dom.document, + &mut children, + &ParseHtmlNodeContext::default(), + ); + + Some(ParsedHtmlBlock { + source_range, + children, + }) +} + +fn cleanup_html(source: &str) -> Vec { + let mut writer = std::io::Cursor::new(Vec::new()); + let mut reader = std::io::Cursor::new(source); + let mut minify = Minifier::new( + &mut writer, + MinifierOptions { + omit_doctype: true, + collapse_whitespace: true, + ..Default::default() + }, + ); + if let Ok(()) = minify.minify(&mut reader) { + writer.into_inner() + } else { + source.bytes().collect() + } +} + +#[stacksafe] +fn parse_html_node( + source_range: Range, + node: &Node, + elements: &mut Vec, + context: &ParseHtmlNodeContext, +) { + match &node.data { + NodeData::Document => { + consume_children(source_range, node, elements, context); + } + NodeData::Text { contents } => { + elements.push(ParsedHtmlElement::Paragraph(vec![ + HtmlParagraphChunk::Text(ParsedHtmlText { + source_range, + highlights: Vec::default(), + links: Vec::default(), + contents: contents.borrow().to_string().into(), + }), + ])); + } + NodeData::Comment { .. } => {} + NodeData::Element { name, attrs, .. } => { + let mut styles = if let Some(styles) = + html_style_from_html_styles(extract_styles_from_attributes(attrs)) + { + vec![styles] + } else { + Vec::default() + }; + + if name.local == local_name!("img") { + if let Some(image) = extract_image(source_range, attrs) { + elements.push(ParsedHtmlElement::Image(image)); + } + } else if name.local == local_name!("p") { + let mut paragraph = HtmlParagraph::new(); + parse_paragraph( + source_range, + node, + &mut paragraph, + &mut styles, + &mut Vec::new(), + ); + + if !paragraph.is_empty() { + elements.push(ParsedHtmlElement::Paragraph(paragraph)); + } + } else if matches!( + name.local, + local_name!("h1") + | local_name!("h2") + | local_name!("h3") + | local_name!("h4") + | local_name!("h5") + | local_name!("h6") + ) { + let mut paragraph = HtmlParagraph::new(); + consume_paragraph( + source_range.clone(), + node, + &mut paragraph, + &mut styles, + &mut Vec::new(), + ); + + if !paragraph.is_empty() { + elements.push(ParsedHtmlElement::Heading(ParsedHtmlHeading { + source_range, + level: match name.local { + local_name!("h1") => HeadingLevel::H1, + local_name!("h2") => HeadingLevel::H2, + local_name!("h3") => HeadingLevel::H3, + local_name!("h4") => HeadingLevel::H4, + local_name!("h5") => HeadingLevel::H5, + local_name!("h6") => HeadingLevel::H6, + _ => unreachable!(), + }, + contents: paragraph, + })); + } + } else if name.local == local_name!("ul") || name.local == local_name!("ol") { + if let Some(list) = extract_html_list( + node, + name.local == local_name!("ol"), + context.list_item_depth, + source_range, + ) { + elements.push(ParsedHtmlElement::List(list)); + } + } else if name.local == local_name!("blockquote") { + if let Some(blockquote) = extract_html_blockquote(node, source_range) { + elements.push(ParsedHtmlElement::BlockQuote(blockquote)); + } + } else if name.local == local_name!("table") { + if let Some(table) = extract_html_table(node, source_range) { + elements.push(ParsedHtmlElement::Table(table)); + } + } else { + consume_children(source_range, node, elements, context); + } + } + _ => {} + } +} + +#[stacksafe] +fn parse_paragraph( + source_range: Range, + node: &Node, + paragraph: &mut HtmlParagraph, + highlights: &mut Vec, + links: &mut Vec, +) { + fn items_with_range( + range: Range, + items: impl IntoIterator, + ) -> Vec<(Range, T)> { + items + .into_iter() + .map(|item| (range.clone(), item)) + .collect() + } + + match &node.data { + NodeData::Text { contents } => { + if let Some(text) = + paragraph + .iter_mut() + .last() + .and_then(|paragraph_chunk| match paragraph_chunk { + HtmlParagraphChunk::Text(text) => Some(text), + _ => None, + }) + { + let mut new_text = text.contents.to_string(); + new_text.push_str(&contents.borrow()); + + text.highlights.extend(items_with_range( + text.contents.len()..new_text.len(), + mem::take(highlights), + )); + text.links.extend(items_with_range( + text.contents.len()..new_text.len(), + mem::take(links), + )); + text.contents = SharedString::from(new_text); + } else { + let contents = contents.borrow().to_string(); + paragraph.push(HtmlParagraphChunk::Text(ParsedHtmlText { + source_range, + highlights: items_with_range(0..contents.len(), mem::take(highlights)), + links: items_with_range(0..contents.len(), mem::take(links)), + contents: contents.into(), + })); + } + } + NodeData::Element { name, attrs, .. } => { + if name.local == local_name!("img") { + if let Some(image) = extract_image(source_range, attrs) { + paragraph.push(HtmlParagraphChunk::Image(image)); + } + } else if name.local == local_name!("b") || name.local == local_name!("strong") { + highlights.push(HtmlHighlightStyle { + weight: FontWeight::BOLD, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("i") { + highlights.push(HtmlHighlightStyle { + italic: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("em") { + highlights.push(HtmlHighlightStyle { + oblique: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("del") { + highlights.push(HtmlHighlightStyle { + strikethrough: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("ins") { + highlights.push(HtmlHighlightStyle { + underline: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("a") { + if let Some(url) = attr_value(attrs, local_name!("href")) { + highlights.push(HtmlHighlightStyle { + link: true, + ..Default::default() + }); + links.push(url.into()); + } + consume_paragraph(source_range, node, paragraph, highlights, links); + } else { + consume_paragraph(source_range, node, paragraph, highlights, links); + } + } + _ => {} + } +} + +fn consume_paragraph( + source_range: Range, + node: &Node, + paragraph: &mut HtmlParagraph, + highlights: &mut Vec, + links: &mut Vec, +) { + for child in node.children.borrow().iter() { + parse_paragraph(source_range.clone(), child, paragraph, highlights, links); + } +} + +fn parse_table_row(source_range: Range, node: &Node) -> Option { + let mut columns = Vec::new(); + + if let NodeData::Element { name, .. } = &node.data { + if name.local != local_name!("tr") { + return None; + } + + for child in node.children.borrow().iter() { + if let Some(column) = parse_table_column(source_range.clone(), child) { + columns.push(column); + } + } + } + + if columns.is_empty() { + None + } else { + Some(ParsedHtmlTableRow { columns }) + } +} + +fn parse_table_column(source_range: Range, node: &Node) -> Option { + match &node.data { + NodeData::Element { name, attrs, .. } => { + if !matches!(name.local, local_name!("th") | local_name!("td")) { + return None; + } + + let mut children = HtmlParagraph::new(); + consume_paragraph( + source_range, + node, + &mut children, + &mut Vec::new(), + &mut Vec::new(), + ); + + let is_header = name.local == local_name!("th"); + + Some(ParsedHtmlTableColumn { + col_span: std::cmp::max( + attr_value(attrs, local_name!("colspan")) + .and_then(|span| span.parse().ok()) + .unwrap_or(1), + 1, + ), + row_span: std::cmp::max( + attr_value(attrs, local_name!("rowspan")) + .and_then(|span| span.parse().ok()) + .unwrap_or(1), + 1, + ), + is_header, + children, + alignment: attr_value(attrs, local_name!("align")) + .and_then(|align| match align.as_str() { + "left" => Some(Alignment::Left), + "center" => Some(Alignment::Center), + "right" => Some(Alignment::Right), + _ => None, + }) + .unwrap_or(if is_header { + Alignment::Center + } else { + Alignment::None + }), + }) + } + _ => None, + } +} + +fn consume_children( + source_range: Range, + node: &Node, + elements: &mut Vec, + context: &ParseHtmlNodeContext, +) { + for child in node.children.borrow().iter() { + parse_html_node(source_range.clone(), child, elements, context); + } +} + +fn attr_value(attrs: &RefCell>, name: LocalName) -> Option { + attrs.borrow().iter().find_map(|attr| { + if attr.name.local == name { + Some(attr.value.to_string()) + } else { + None + } + }) +} + +fn html_style_from_html_styles(styles: HashMap) -> Option { + let mut html_style = HtmlHighlightStyle::default(); + + if let Some(text_decoration) = styles.get("text-decoration") { + match text_decoration.to_lowercase().as_str() { + "underline" => { + html_style.underline = true; + } + "line-through" => { + html_style.strikethrough = true; + } + _ => {} + } + } + + if let Some(font_style) = styles.get("font-style") { + match font_style.to_lowercase().as_str() { + "italic" => { + html_style.italic = true; + } + "oblique" => { + html_style.oblique = true; + } + _ => {} + } + } + + if let Some(font_weight) = styles.get("font-weight") { + match font_weight.to_lowercase().as_str() { + "bold" => { + html_style.weight = FontWeight::BOLD; + } + "lighter" => { + html_style.weight = FontWeight::THIN; + } + _ => { + if let Ok(weight) = font_weight.parse::() { + html_style.weight = FontWeight(weight); + } + } + } + } + + if html_style != HtmlHighlightStyle::default() { + Some(html_style) + } else { + None + } +} + +fn extract_styles_from_attributes(attrs: &RefCell>) -> HashMap { + let mut styles = HashMap::new(); + + if let Some(style) = attr_value(attrs, local_name!("style")) { + for declaration in style.split(';') { + let mut parts = declaration.splitn(2, ':'); + if let Some((key, value)) = parts.next().zip(parts.next()) { + styles.insert(key.trim().to_lowercase(), value.trim().to_string()); + } + } + } + + styles +} + +fn extract_image(source_range: Range, attrs: &RefCell>) -> Option { + let src = attr_value(attrs, local_name!("src"))?; + + let mut image = HtmlImage::new(src, source_range); + + if let Some(alt) = attr_value(attrs, local_name!("alt")) { + image.set_alt_text(alt.into()); + } + + let styles = extract_styles_from_attributes(attrs); + + if let Some(width) = attr_value(attrs, local_name!("width")) + .or_else(|| styles.get("width").cloned()) + .and_then(|width| parse_html_element_dimension(&width)) + { + image.set_width(width); + } + + if let Some(height) = attr_value(attrs, local_name!("height")) + .or_else(|| styles.get("height").cloned()) + .and_then(|height| parse_html_element_dimension(&height)) + { + image.set_height(height); + } + + Some(image) +} + +fn extract_html_list( + node: &Node, + ordered: bool, + depth: u16, + source_range: Range, +) -> Option { + let mut items = Vec::with_capacity(node.children.borrow().len()); + + for (index, child) in node.children.borrow().iter().enumerate() { + if let NodeData::Element { name, .. } = &child.data { + if name.local != local_name!("li") { + continue; + } + + let mut content = Vec::new(); + consume_children( + source_range.clone(), + child, + &mut content, + &ParseHtmlNodeContext { + list_item_depth: depth + 1, + }, + ); + + if !content.is_empty() { + items.push(ParsedHtmlListItem { + source_range: source_range.clone(), + item_type: if ordered { + ParsedHtmlListItemType::Ordered(index as u64 + 1) + } else { + ParsedHtmlListItemType::Unordered + }, + content, + }); + } + } + } + + if items.is_empty() { + None + } else { + Some(ParsedHtmlList { + source_range, + depth, + ordered, + items, + }) + } +} + +fn parse_html_element_dimension(value: &str) -> Option { + if value.ends_with('%') { + value + .trim_end_matches('%') + .parse::() + .ok() + .map(|value| relative(value / 100.)) + } else { + value + .trim_end_matches("px") + .parse() + .ok() + .map(|value| px(value).into()) + } +} + +fn extract_html_blockquote( + node: &Node, + source_range: Range, +) -> Option { + let mut children = Vec::new(); + consume_children( + source_range.clone(), + node, + &mut children, + &ParseHtmlNodeContext::default(), + ); + + if children.is_empty() { + None + } else { + Some(ParsedHtmlBlockQuote { + children, + source_range, + }) + } +} + +fn extract_html_table(node: &Node, source_range: Range) -> Option { + let mut header_rows = Vec::new(); + let mut body_rows = Vec::new(); + let mut caption = None; + + for child in node.children.borrow().iter() { + if let NodeData::Element { name, .. } = &child.data { + if name.local == local_name!("caption") { + let mut paragraph = HtmlParagraph::new(); + parse_paragraph( + source_range.clone(), + child, + &mut paragraph, + &mut Vec::new(), + &mut Vec::new(), + ); + caption = Some(paragraph); + } + + if name.local == local_name!("thead") { + for row in child.children.borrow().iter() { + if let Some(row) = parse_table_row(source_range.clone(), row) { + header_rows.push(row); + } + } + } else if name.local == local_name!("tbody") { + for row in child.children.borrow().iter() { + if let Some(row) = parse_table_row(source_range.clone(), row) { + body_rows.push(row); + } + } + } + } + } + + if !header_rows.is_empty() || !body_rows.is_empty() { + Some(ParsedHtmlTable { + source_range, + body: body_rows, + header: header_rows, + caption, + }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_html_styled_text() { + let parsed = parse_html_block( + "

Some text strong link

", + 0..79, + ) + .unwrap(); + + assert_eq!(parsed.children.len(), 1); + let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else { + panic!("expected paragraph"); + }; + let HtmlParagraphChunk::Text(text) = ¶graph[0] else { + panic!("expected text chunk"); + }; + + assert_eq!(text.contents.as_ref(), "Some text strong link"); + assert_eq!( + text.highlights, + vec![ + ( + 10..16, + HtmlHighlightStyle { + weight: FontWeight::BOLD, + ..Default::default() + } + ), + ( + 17..21, + HtmlHighlightStyle { + link: true, + ..Default::default() + } + ) + ] + ); + assert_eq!( + text.links, + vec![(17..21, SharedString::from("https://example.com"))] + ); + } + + #[test] + fn parses_html_table_spans() { + let parsed = parse_html_block( + "
a
bc
", + 0..91, + ) + .unwrap(); + + let ParsedHtmlElement::Table(table) = &parsed.children[0] else { + panic!("expected table"); + }; + assert_eq!(table.body.len(), 2); + assert_eq!(table.body[0].columns[0].col_span, 2); + assert_eq!(table.body[1].columns.len(), 2); + } + + #[test] + fn parses_html_list_as_explicit_list_node() { + let parsed = parse_html_block( + "
  • parent
    • child
  • sibling
", + 0..64, + ) + .unwrap(); + + assert_eq!(parsed.children.len(), 1); + + let ParsedHtmlElement::List(list) = &parsed.children[0] else { + panic!("expected list"); + }; + + assert!(!list.ordered); + assert_eq!(list.depth, 1); + assert_eq!(list.items.len(), 2); + + let first_item = &list.items[0]; + let ParsedHtmlElement::Paragraph(paragraph) = &first_item.content[0] else { + panic!("expected first item paragraph"); + }; + let HtmlParagraphChunk::Text(text) = ¶graph[0] else { + panic!("expected first item text"); + }; + assert_eq!(text.contents.as_ref(), "parent"); + + let ParsedHtmlElement::List(nested_list) = &first_item.content[1] else { + panic!("expected nested list"); + }; + assert_eq!(nested_list.depth, 2); + assert_eq!(nested_list.items.len(), 1); + + let ParsedHtmlElement::Paragraph(nested_paragraph) = &nested_list.items[0].content[0] + else { + panic!("expected nested item paragraph"); + }; + let HtmlParagraphChunk::Text(nested_text) = &nested_paragraph[0] else { + panic!("expected nested item text"); + }; + assert_eq!(nested_text.contents.as_ref(), "child"); + + let second_item = &list.items[1]; + let ParsedHtmlElement::Paragraph(second_paragraph) = &second_item.content[0] else { + panic!("expected second item paragraph"); + }; + let HtmlParagraphChunk::Text(second_text) = &second_paragraph[0] else { + panic!("expected second item text"); + }; + assert_eq!(second_text.contents.as_ref(), "sibling"); + } +} diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs new file mode 100644 index 0000000000000000000000000000000000000000..6b52a98908ed8757986d7ca7f8778b330f97254f --- /dev/null +++ b/crates/markdown/src/html/html_rendering.rs @@ -0,0 +1,613 @@ +use std::ops::Range; + +use gpui::{App, FontStyle, FontWeight, StrikethroughStyle, TextStyleRefinement, UnderlineStyle}; +use pulldown_cmark::Alignment; +use ui::prelude::*; + +use crate::html::html_parser::{ + HtmlHighlightStyle, HtmlImage, HtmlParagraph, HtmlParagraphChunk, ParsedHtmlBlock, + ParsedHtmlElement, ParsedHtmlList, ParsedHtmlListItemType, ParsedHtmlTable, ParsedHtmlTableRow, + ParsedHtmlText, +}; +use crate::{MarkdownElement, MarkdownElementBuilder}; + +pub(crate) struct HtmlSourceAllocator { + source_range: Range, + next_source_index: usize, +} + +impl HtmlSourceAllocator { + pub(crate) fn new(source_range: Range) -> Self { + Self { + next_source_index: source_range.start, + source_range, + } + } + + pub(crate) fn allocate(&mut self, requested_len: usize) -> Range { + let remaining = self.source_range.end.saturating_sub(self.next_source_index); + let len = requested_len.min(remaining); + let start = self.next_source_index; + let end = start + len; + self.next_source_index = end; + start..end + } +} + +impl MarkdownElement { + pub(crate) fn render_html_block( + &self, + block: &ParsedHtmlBlock, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + let mut source_allocator = HtmlSourceAllocator::new(block.source_range.clone()); + self.render_html_elements( + &block.children, + &mut source_allocator, + builder, + markdown_end, + cx, + ); + } + + fn render_html_elements( + &self, + elements: &[ParsedHtmlElement], + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + for element in elements { + self.render_html_element(element, source_allocator, builder, markdown_end, cx); + } + } + + fn render_html_element( + &self, + element: &ParsedHtmlElement, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + let Some(source_range) = element.source_range() else { + return; + }; + + match element { + ParsedHtmlElement::Paragraph(paragraph) => { + self.push_markdown_paragraph(builder, &source_range, markdown_end); + self.render_html_paragraph(paragraph, source_allocator, builder, cx, markdown_end); + builder.pop_div(); + } + ParsedHtmlElement::Heading(heading) => { + self.push_markdown_heading( + builder, + heading.level, + &heading.source_range, + markdown_end, + ); + self.render_html_paragraph( + &heading.contents, + source_allocator, + builder, + cx, + markdown_end, + ); + self.pop_markdown_heading(builder); + } + ParsedHtmlElement::List(list) => { + self.render_html_list(list, source_allocator, builder, markdown_end, cx); + } + ParsedHtmlElement::BlockQuote(block_quote) => { + self.push_markdown_block_quote(builder, &block_quote.source_range, markdown_end); + self.render_html_elements( + &block_quote.children, + source_allocator, + builder, + markdown_end, + cx, + ); + self.pop_markdown_block_quote(builder); + } + ParsedHtmlElement::Table(table) => { + self.render_html_table(table, source_allocator, builder, markdown_end, cx); + } + ParsedHtmlElement::Image(image) => { + self.render_html_image(image, builder); + } + } + } + + fn render_html_list( + &self, + list: &ParsedHtmlList, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + builder.push_div(div().pl_2p5(), &list.source_range, markdown_end); + + for list_item in &list.items { + let bullet = match list_item.item_type { + ParsedHtmlListItemType::Ordered(order) => html_list_item_prefix( + order as usize, + list.ordered, + list.depth.saturating_sub(1) as usize, + ), + ParsedHtmlListItemType::Unordered => { + html_list_item_prefix(1, false, list.depth.saturating_sub(1) as usize) + } + }; + + self.push_markdown_list_item( + builder, + div().child(bullet).into_any_element(), + &list_item.source_range, + markdown_end, + ); + self.render_html_elements( + &list_item.content, + source_allocator, + builder, + markdown_end, + cx, + ); + self.pop_markdown_list_item(builder); + } + + builder.pop_div(); + } + + fn render_html_table( + &self, + table: &ParsedHtmlTable, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + if let Some(caption) = &table.caption { + builder.push_div( + div().when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_2().line_height(rems(1.3)) + }), + &table.source_range, + markdown_end, + ); + self.render_html_paragraph(caption, source_allocator, builder, cx, markdown_end); + builder.pop_div(); + } + + let actual_header_column_count = html_table_columns_count(&table.header); + let actual_body_column_count = html_table_columns_count(&table.body); + let max_column_count = actual_header_column_count.max(actual_body_column_count); + + if max_column_count == 0 { + return; + } + + let total_rows = table.header.len() + table.body.len(); + let mut grid_occupied = vec![vec![false; max_column_count]; total_rows]; + + builder.push_div( + div() + .id(("html-table", table.source_range.start)) + .grid() + .grid_cols(max_column_count as u16) + .when(self.style.table_columns_min_size, |this| { + this.grid_cols_min_content(max_column_count as u16) + }) + .when(!self.style.table_columns_min_size, |this| { + this.grid_cols(max_column_count as u16) + }) + .w_full() + .mb_2() + .border(px(1.5)) + .border_color(cx.theme().colors().border) + .rounded_sm() + .overflow_hidden(), + &table.source_range, + markdown_end, + ); + + for (row_index, row) in table.header.iter().chain(table.body.iter()).enumerate() { + let mut column_index = 0; + + for cell in &row.columns { + while column_index < max_column_count && grid_occupied[row_index][column_index] { + column_index += 1; + } + + if column_index >= max_column_count { + break; + } + + let max_span = max_column_count.saturating_sub(column_index); + let mut cell_div = div() + .col_span(cell.col_span.min(max_span) as u16) + .row_span(cell.row_span.min(total_rows - row_index) as u16) + .when(column_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) + .border_color(cx.theme().colors().border) + .px_2() + .py_1() + .when(cell.is_header, |this| { + this.bg(cx.theme().colors().title_bar_background) + }) + .when(!cell.is_header && row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }); + + cell_div = match cell.alignment { + Alignment::Center => cell_div.items_center(), + Alignment::Right => cell_div.items_end(), + _ => cell_div, + }; + + builder.push_div(cell_div, &table.source_range, markdown_end); + self.render_html_paragraph( + &cell.children, + source_allocator, + builder, + cx, + markdown_end, + ); + builder.pop_div(); + + for row_offset in 0..cell.row_span { + for column_offset in 0..cell.col_span { + if row_index + row_offset < total_rows + && column_index + column_offset < max_column_count + { + grid_occupied[row_index + row_offset][column_index + column_offset] = + true; + } + } + } + + column_index += cell.col_span; + } + + while column_index < max_column_count { + if grid_occupied[row_index][column_index] { + column_index += 1; + continue; + } + + builder.push_div( + div() + .when(column_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) + .border_color(cx.theme().colors().border) + .when(row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }), + &table.source_range, + markdown_end, + ); + builder.pop_div(); + column_index += 1; + } + } + + builder.pop_div(); + } + + fn render_html_paragraph( + &self, + paragraph: &HtmlParagraph, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + cx: &mut App, + _markdown_end: usize, + ) { + for chunk in paragraph { + match chunk { + HtmlParagraphChunk::Text(text) => { + self.render_html_text(text, source_allocator, builder, cx); + } + HtmlParagraphChunk::Image(image) => { + self.render_html_image(image, builder); + } + } + } + } + + fn render_html_text( + &self, + text: &ParsedHtmlText, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + cx: &mut App, + ) { + let text_contents = text.contents.as_ref(); + if text_contents.is_empty() { + return; + } + + let allocated_range = source_allocator.allocate(text_contents.len()); + let allocated_len = allocated_range.end.saturating_sub(allocated_range.start); + + let mut boundaries = vec![0, text_contents.len()]; + for (range, _) in &text.highlights { + boundaries.push(range.start); + boundaries.push(range.end); + } + for (range, _) in &text.links { + boundaries.push(range.start); + boundaries.push(range.end); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + for segment in boundaries.windows(2) { + let start = segment[0]; + let end = segment[1]; + if start >= end { + continue; + } + + let source_start = allocated_range.start + start.min(allocated_len); + let source_end = allocated_range.start + end.min(allocated_len); + if source_start >= source_end { + continue; + } + + let mut refinement = TextStyleRefinement::default(); + let mut has_refinement = false; + + for (highlight_range, style) in &text.highlights { + if highlight_range.start < end && highlight_range.end > start { + apply_html_highlight_style(&mut refinement, style); + has_refinement = true; + } + } + + let link = text.links.iter().find_map(|(link_range, link)| { + if link_range.start < end && link_range.end > start { + Some(link.clone()) + } else { + None + } + }); + + if let Some(link) = link.as_ref() { + builder.push_link(link.clone(), source_start..source_end); + let link_style = self + .style + .link_callback + .as_ref() + .and_then(|callback| callback(link.as_ref(), cx)) + .unwrap_or_else(|| self.style.link.clone()); + builder.push_text_style(link_style); + } + + if has_refinement { + builder.push_text_style(refinement); + } + + builder.push_text(&text_contents[start..end], source_start..source_end); + + if has_refinement { + builder.pop_text_style(); + } + + if link.is_some() { + builder.pop_text_style(); + } + } + } + + fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) { + let Some(source) = self + .image_resolver + .as_ref() + .and_then(|resolve| resolve(image.dest_url.as_ref())) + else { + return; + }; + + self.push_markdown_image( + builder, + &image.source_range, + source, + image.width, + image.height, + ); + } +} + +fn apply_html_highlight_style(refinement: &mut TextStyleRefinement, style: &HtmlHighlightStyle) { + if style.weight != FontWeight::default() { + refinement.font_weight = Some(style.weight); + } + + if style.oblique { + refinement.font_style = Some(FontStyle::Oblique); + } else if style.italic { + refinement.font_style = Some(FontStyle::Italic); + } + + if style.underline { + refinement.underline = Some(UnderlineStyle { + thickness: px(1.), + color: None, + ..Default::default() + }); + } + + if style.strikethrough { + refinement.strikethrough = Some(StrikethroughStyle { + thickness: px(1.), + color: None, + }); + } +} + +fn html_list_item_prefix(order: usize, ordered: bool, depth: usize) -> String { + let index = order.saturating_sub(1); + const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz"; + const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"]; + + if ordered { + match depth { + 0 => format!("{}. ", order), + 1 => format!( + "{}. ", + NUMBERED_PREFIXES_1 + .chars() + .nth(index % NUMBERED_PREFIXES_1.len()) + .unwrap() + ), + _ => format!( + "{}. ", + NUMBERED_PREFIXES_2 + .chars() + .nth(index % NUMBERED_PREFIXES_2.len()) + .unwrap() + ), + } + } else { + let depth = depth.min(BULLETS.len() - 1); + format!("{} ", BULLETS[depth]) + } +} + +fn html_table_columns_count(rows: &[ParsedHtmlTableRow]) -> usize { + let mut actual_column_count = 0; + for row in rows { + actual_column_count = actual_column_count.max( + row.columns + .iter() + .map(|column| column.col_span) + .sum::(), + ); + } + actual_column_count +} + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, size}; + use ui::prelude::*; + + use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle}; + + fn ensure_theme_initialized(cx: &mut TestAppContext) { + cx.update(|cx| { + if !cx.has_global::() { + settings::init(cx); + } + if !cx.has_global::() { + theme::init(theme::LoadThemes::JustBase, cx); + } + }); + } + + fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx)); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + rendered.text + } + + #[gpui::test] + fn test_html_block_rendering_smoke(cx: &mut TestAppContext) { + let rendered = render_markdown_text( + "

Hello

world

  • item
", + cx, + ); + + let rendered_lines = rendered + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>(); + + assert_eq!( + rendered_lines.concat().replace('\n', ""), + "

Hello

world

  • item
" + ); + } + + #[gpui::test] + fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + "

Hello

world

  • item
".into(), + None, + None, + MarkdownOptions { + parse_html: true, + ..Default::default() + }, + cx, + ) + }); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + + let rendered_lines = rendered + .text + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>(); + + assert_eq!(rendered_lines[0], "Hello"); + assert_eq!(rendered_lines[1], "world"); + assert!(rendered_lines.iter().any(|line| line.contains("item"))); + } +} diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index edff18e8eb14f42d380ef5081d9de25b82417fd5..7a8c50e0d0662e251173c2d433aee8ba2d5d3af7 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1,3 +1,5 @@ +pub mod html; +mod mermaid; pub mod parser; mod path_range; @@ -9,6 +11,9 @@ use gpui::UnderlineStyle; use language::LanguageName; use log::Level; +use mermaid::{ + MermaidState, ParsedMarkdownMermaidDiagram, extract_mermaid_diagrams, render_mermaid_diagram, +}; pub use path_range::{LineCol, PathWithRange}; use settings::Settings as _; use theme::ThemeSettings; @@ -29,13 +34,16 @@ use collections::{HashMap, HashSet}; use gpui::{ AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity, FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image, - ImageFormat, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent, - MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText, - Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad, + ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, + MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, + StyleRefinement, StyledText, Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, + actions, img, point, quad, }; use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; -use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown}; +use parser::{ + MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options, +}; use pulldown_cmark::Alignment; use sum_tree::TreeMap; use theme::SyntaxTheme; @@ -47,7 +55,8 @@ use crate::parser::CodeBlockKind; /// A callback function that can be used to customize the style of links based on the destination URL. /// If the callback returns `None`, the default link style will be used. type LinkStyleCallback = Rc Option>; - +type SourceClickCallback = Box bool>; +type CheckboxToggleCallback = Rc, bool, &mut Window, &mut App)>; /// Defines custom style refinements for each heading level (H1-H6) #[derive(Clone, Default)] pub struct HeadingLevelStyles { @@ -239,6 +248,7 @@ pub struct Markdown { selection: Selection, pressed_link: Option, autoscroll_request: Option, + active_root_block: Option, parsed_markdown: ParsedMarkdown, images_by_source_offset: HashMap>, should_reparse: bool, @@ -246,14 +256,18 @@ pub struct Markdown { focus_handle: FocusHandle, language_registry: Option>, fallback_code_block_language: Option, - options: Options, + options: MarkdownOptions, + mermaid_state: MermaidState, copied_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, context_menu_selected_text: Option, } -struct Options { - parse_links_only: bool, +#[derive(Clone, Copy, Default)] +pub struct MarkdownOptions { + pub parse_links_only: bool, + pub parse_html: bool, + pub render_mermaid_diagrams: bool, } pub enum CodeBlockRenderer { @@ -300,6 +314,22 @@ impl Markdown { language_registry: Option>, fallback_code_block_language: Option, cx: &mut Context, + ) -> Self { + Self::new_with_options( + source, + language_registry, + fallback_code_block_language, + MarkdownOptions::default(), + cx, + ) + } + + pub fn new_with_options( + source: SharedString, + language_registry: Option>, + fallback_code_block_language: Option, + options: MarkdownOptions, + cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); let mut this = Self { @@ -307,6 +337,7 @@ impl Markdown { selection: Selection::default(), pressed_link: None, autoscroll_request: None, + active_root_block: None, should_reparse: false, images_by_source_offset: Default::default(), parsed_markdown: ParsedMarkdown::default(), @@ -314,9 +345,8 @@ impl Markdown { focus_handle, language_registry, fallback_code_block_language, - options: Options { - parse_links_only: false, - }, + options, + mermaid_state: MermaidState::default(), copied_code_blocks: HashSet::default(), code_block_scroll_handles: BTreeMap::default(), context_menu_selected_text: None, @@ -326,28 +356,16 @@ impl Markdown { } pub fn new_text(source: SharedString, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); - let mut this = Self { + Self::new_with_options( source, - selection: Selection::default(), - pressed_link: None, - autoscroll_request: None, - should_reparse: false, - parsed_markdown: ParsedMarkdown::default(), - images_by_source_offset: Default::default(), - pending_parse: None, - focus_handle, - language_registry: None, - fallback_code_block_language: None, - options: Options { + None, + None, + MarkdownOptions { parse_links_only: true, + ..Default::default() }, - copied_code_blocks: HashSet::default(), - code_block_scroll_handles: BTreeMap::default(), - context_menu_selected_text: None, - }; - this.parse(cx); - this + cx, + ) } fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle { @@ -410,6 +428,30 @@ impl Markdown { self.parse(cx); } + pub fn request_autoscroll_to_source_index( + &mut self, + source_index: usize, + cx: &mut Context, + ) { + self.autoscroll_request = Some(source_index); + cx.refresh_windows(); + } + + pub fn set_active_root_for_source_index( + &mut self, + source_index: Option, + cx: &mut Context, + ) { + let active_root_block = + source_index.and_then(|index| self.parsed_markdown.root_block_for_source_index(index)); + if self.active_root_block == active_root_block { + return; + } + + self.active_root_block = active_root_block; + cx.notify(); + } + pub fn reset(&mut self, source: SharedString, cx: &mut Context) { if source == self.source() { return; @@ -489,6 +531,17 @@ impl Markdown { fn parse(&mut self, cx: &mut Context) { if self.source.is_empty() { + self.should_reparse = false; + self.pending_parse.take(); + self.parsed_markdown = ParsedMarkdown { + source: self.source.clone(), + ..Default::default() + }; + self.active_root_block = None; + self.images_by_source_offset.clear(); + self.mermaid_state.clear(); + cx.notify(); + cx.refresh_windows(); return; } @@ -503,6 +556,8 @@ impl Markdown { fn start_background_parse(&self, cx: &Context) -> Task<()> { let source = self.source.clone(); let should_parse_links_only = self.options.parse_links_only; + let should_parse_html = self.options.parse_html; + let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams; let language_registry = self.language_registry.clone(); let fallback = self.fallback_code_block_language.clone(); @@ -514,12 +569,25 @@ impl Markdown { source, languages_by_name: TreeMap::default(), languages_by_path: TreeMap::default(), + root_block_starts: Arc::default(), + html_blocks: BTreeMap::default(), + mermaid_diagrams: BTreeMap::default(), }, Default::default(), ); } - let (events, language_names, paths) = parse_markdown(&source); + let parsed = parse_markdown_with_options(&source, should_parse_html); + let events = parsed.events; + let language_names = parsed.language_names; + let paths = parsed.language_paths; + let root_block_starts = parsed.root_block_starts; + let html_blocks = parsed.html_blocks; + let mermaid_diagrams = if should_render_mermaid_diagrams { + extract_mermaid_diagrams(&source, &events) + } else { + BTreeMap::default() + }; let mut images_by_source_offset = HashMap::default(); let mut languages_by_name = TreeMap::default(); let mut languages_by_path = TreeMap::default(); @@ -578,6 +646,9 @@ impl Markdown { events: Arc::from(events), languages_by_name, languages_by_path, + root_block_starts: Arc::from(root_block_starts), + html_blocks, + mermaid_diagrams, }, images_by_source_offset, ) @@ -589,10 +660,22 @@ impl Markdown { this.update(cx, |this, cx| { this.parsed_markdown = parsed; this.images_by_source_offset = images_by_source_offset; + if this.active_root_block.is_some_and(|block_index| { + block_index >= this.parsed_markdown.root_block_starts.len() + }) { + this.active_root_block = None; + } + if this.options.render_mermaid_diagrams { + let parsed_markdown = this.parsed_markdown.clone(); + this.mermaid_state.update(&parsed_markdown, cx); + } else { + this.mermaid_state.clear(); + } this.pending_parse.take(); if this.should_reparse { this.parse(cx); } + cx.notify(); cx.refresh_windows(); }) .ok(); @@ -686,6 +769,9 @@ pub struct ParsedMarkdown { pub events: Arc<[(Range, MarkdownEvent)]>, pub languages_by_name: TreeMap>, pub languages_by_path: TreeMap, Arc>, + pub root_block_starts: Arc<[usize]>, + pub(crate) html_blocks: BTreeMap, + pub(crate) mermaid_diagrams: BTreeMap, } impl ParsedMarkdown { @@ -696,6 +782,30 @@ impl ParsedMarkdown { pub fn events(&self) -> &Arc<[(Range, MarkdownEvent)]> { &self.events } + + pub fn root_block_starts(&self) -> &Arc<[usize]> { + &self.root_block_starts + } + + pub fn root_block_for_source_index(&self, source_index: usize) -> Option { + if self.root_block_starts.is_empty() { + return None; + } + + let partition = self + .root_block_starts + .partition_point(|block_start| *block_start <= source_index); + + Some(partition.saturating_sub(1)) + } +} + +pub enum AutoscrollBehavior { + /// Propagate the request up the element tree for the nearest + /// scrollable ancestor (e.g. `List`) to handle. + Propagate, + /// Directly control a specific scroll handle. + Controlled(ScrollHandle), } pub struct MarkdownElement { @@ -703,6 +813,11 @@ pub struct MarkdownElement { style: MarkdownStyle, code_block_renderer: CodeBlockRenderer, on_url_click: Option>, + on_source_click: Option, + on_checkbox_toggle: Option, + image_resolver: Option Option>>, + show_root_block_markers: bool, + autoscroll: AutoscrollBehavior, } impl MarkdownElement { @@ -716,6 +831,11 @@ impl MarkdownElement { border: false, }, on_url_click: None, + on_source_click: None, + on_checkbox_toggle: None, + image_resolver: None, + show_root_block_markers: false, + autoscroll: AutoscrollBehavior::Propagate, } } @@ -753,6 +873,147 @@ impl MarkdownElement { self } + pub fn on_source_click( + mut self, + handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_source_click = Some(Box::new(handler)); + self + } + + pub fn on_checkbox_toggle( + mut self, + handler: impl Fn(Range, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_checkbox_toggle = Some(Rc::new(handler)); + self + } + + pub fn image_resolver( + mut self, + resolver: impl Fn(&str) -> Option + 'static, + ) -> Self { + self.image_resolver = Some(Box::new(resolver)); + self + } + + pub fn show_root_block_markers(mut self) -> Self { + self.show_root_block_markers = true; + self + } + + pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self { + self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle); + self + } + + fn push_markdown_image( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + source: ImageSource, + width: Option, + height: Option, + ) { + builder.modify_current_div(|el| { + el.items_center().flex().flex_row().child( + img(source) + .max_w_full() + .when_some(height, |this, height| this.h(height)) + .when_some(width, |this, width| this.w(width)), + ) + }); + let _ = range; + } + + fn push_markdown_paragraph( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + markdown_end: usize, + ) { + builder.push_div( + div().when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_2().line_height(rems(1.3)) + }), + range, + markdown_end, + ); + } + + fn push_markdown_heading( + &self, + builder: &mut MarkdownElementBuilder, + level: pulldown_cmark::HeadingLevel, + range: &Range, + markdown_end: usize, + ) { + let mut heading = div().mb_2(); + heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref()); + + let mut heading_style = self.style.heading.clone(); + let heading_text_style = heading_style.text_style().clone(); + heading.style().refine(&heading_style); + + builder.push_text_style(heading_text_style); + builder.push_div(heading, range, markdown_end); + } + + fn pop_markdown_heading(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_text_style(); + } + + fn push_markdown_block_quote( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + markdown_end: usize, + ) { + builder.push_text_style(self.style.block_quote.clone()); + builder.push_div( + div() + .pl_4() + .mb_2() + .border_l_4() + .border_color(self.style.block_quote_border_color), + range, + markdown_end, + ); + } + + fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_text_style(); + } + + fn push_markdown_list_item( + &self, + builder: &mut MarkdownElementBuilder, + bullet: AnyElement, + range: &Range, + markdown_end: usize, + ) { + builder.push_div( + div() + .when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_1().gap_1().line_height(rems(1.3)) + }) + .h_flex() + .items_start() + .child(bullet), + range, + markdown_end, + ); + // Without `w_0`, text doesn't wrap to the width of the container. + builder.push_div(div().flex_1().w_0(), range, markdown_end); + } + + fn pop_markdown_list_item(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_div(); + } + fn paint_selection( &self, bounds: Bounds, @@ -846,6 +1107,7 @@ impl MarkdownElement { } let on_open_url = self.on_url_click.take(); + let on_source_click = self.on_source_click.take(); self.on_mouse_event(window, cx, { let hitbox = hitbox.clone(); @@ -873,6 +1135,16 @@ impl MarkdownElement { match rendered_text.source_index_for_position(event.position) { Ok(ix) | Err(ix) => ix, }; + if let Some(handler) = on_source_click.as_ref() { + let blocked = handler(source_index, event.click_count, window, cx); + if blocked { + markdown.selection = Selection::default(); + markdown.pressed_link = None; + window.prevent_default(); + cx.notify(); + return; + } + } let (range, mode) = match event.click_count { 1 => { let range = source_index..source_index; @@ -980,14 +1252,38 @@ impl MarkdownElement { .update(cx, |markdown, _| markdown.autoscroll_request.take())?; let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?; - let text_style = self.style.base_text_style.clone(); - let font_id = window.text_system().resolve_font(&text_style.font()); - let font_size = text_style.font_size.to_pixels(window.rem_size()); - let em_width = window.text_system().em_width(font_id, font_size).unwrap(); - window.request_autoscroll(Bounds::from_corners( - point(position.x - 3. * em_width, position.y - 3. * line_height), - point(position.x + 3. * em_width, position.y + 3. * line_height), - )); + match &self.autoscroll { + AutoscrollBehavior::Controlled(scroll_handle) => { + let viewport = scroll_handle.bounds(); + let margin = line_height * 3.; + let top_goal = viewport.top() + margin; + let bottom_goal = viewport.bottom() - margin; + let current_offset = scroll_handle.offset(); + + let new_offset_y = if position.y < top_goal { + current_offset.y + (top_goal - position.y) + } else if position.y + line_height > bottom_goal { + current_offset.y + (bottom_goal - (position.y + line_height)) + } else { + current_offset.y + }; + + scroll_handle.set_offset(point( + current_offset.x, + new_offset_y.clamp(-scroll_handle.max_offset().y, Pixels::ZERO), + )); + } + AutoscrollBehavior::Propagate => { + let text_style = self.style.base_text_style.clone(); + let font_id = window.text_system().resolve_font(&text_style.font()); + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let em_width = window.text_system().em_width(font_id, font_size).unwrap(); + window.request_autoscroll(Bounds::from_corners( + point(position.x - 3. * em_width, position.y - 3. * line_height), + point(position.x + 3. * em_width, position.y + 3. * line_height), + )); + } + } Some(()) } @@ -1039,11 +1335,14 @@ impl Element for MarkdownElement { self.style.base_text_style.clone(), self.style.syntax.clone(), ); - let (parsed_markdown, images) = { + let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = { let markdown = self.markdown.read(cx); ( markdown.parsed_markdown.clone(), markdown.images_by_source_offset.clone(), + markdown.active_root_block, + markdown.options.render_mermaid_diagrams, + markdown.mermaid_state.clone(), ) }; let markdown_end = if let Some(last) = parsed_markdown.events.last() { @@ -1054,6 +1353,8 @@ impl Element for MarkdownElement { let mut code_block_ids = HashSet::default(); let mut current_img_block_range: Option> = None; + let mut handled_html_block = false; + let mut rendered_mermaid_block = false; for (index, (range, event)) in parsed_markdown.events.iter().enumerate() { // Skip alt text for images that rendered if let Some(current_img_block_range) = ¤t_img_block_range @@ -1062,58 +1363,83 @@ impl Element for MarkdownElement { continue; } + if handled_html_block { + if let MarkdownEvent::End(MarkdownTagEnd::HtmlBlock) = event { + handled_html_block = false; + } else { + continue; + } + } + + if rendered_mermaid_block { + if matches!(event, MarkdownEvent::End(MarkdownTagEnd::CodeBlock)) { + rendered_mermaid_block = false; + } + continue; + } + match event { + MarkdownEvent::RootStart => { + if self.show_root_block_markers { + builder.push_root_block(range, markdown_end); + } + } + MarkdownEvent::RootEnd(root_block_index) => { + if self.show_root_block_markers { + builder.pop_root_block( + active_root_block == Some(*root_block_index), + cx.theme().colors().border, + cx.theme().colors().border_variant, + ); + } + } MarkdownEvent::Start(tag) => { match tag { - MarkdownTag::Image { .. } => { + MarkdownTag::Image { dest_url, .. } => { if let Some(image) = images.get(&range.start) { current_img_block_range = Some(range.clone()); - builder.modify_current_div(|el| { - el.items_center() - .flex() - .flex_row() - .child(img(image.clone())) - }); + self.push_markdown_image( + &mut builder, + range, + image.clone().into(), + None, + None, + ); + } else if let Some(source) = self + .image_resolver + .as_ref() + .and_then(|resolve| resolve(dest_url.as_ref())) + { + current_img_block_range = Some(range.clone()); + self.push_markdown_image(&mut builder, range, source, None, None); } } MarkdownTag::Paragraph => { - builder.push_div( - div().when(!self.style.height_is_multiple_of_line_height, |el| { - el.mb_2().line_height(rems(1.3)) - }), - range, - markdown_end, - ); + self.push_markdown_paragraph(&mut builder, range, markdown_end); } MarkdownTag::Heading { level, .. } => { - let mut heading = div().mb_2(); - - heading = apply_heading_style( - heading, - *level, - self.style.heading_level_styles.as_ref(), - ); - - heading.style().refine(&self.style.heading); - - let text_style = self.style.heading.text_style().clone(); - - builder.push_text_style(text_style); - builder.push_div(heading, range, markdown_end); + self.push_markdown_heading(&mut builder, *level, range, markdown_end); } MarkdownTag::BlockQuote => { - builder.push_text_style(self.style.block_quote.clone()); - builder.push_div( - div() - .pl_4() - .mb_2() - .border_l_4() - .border_color(self.style.block_quote_border_color), - range, - markdown_end, - ); + self.push_markdown_block_quote(&mut builder, range, markdown_end); } MarkdownTag::CodeBlock { kind, .. } => { + if render_mermaid_diagrams + && let Some(mermaid_diagram) = + parsed_markdown.mermaid_diagrams.get(&range.start) + { + builder.push_sourced_element( + mermaid_diagram.content_range.clone(), + render_mermaid_diagram( + mermaid_diagram, + &mermaid_state, + &self.style, + ), + ); + rendered_mermaid_block = true; + continue; + } + let language = match kind { CodeBlockKind::Fenced => None, CodeBlockKind::FencedLang(language) => { @@ -1197,46 +1523,57 @@ impl Element for MarkdownElement { (CodeBlockRenderer::Custom { .. }, _) => {} } } - MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end), + MarkdownTag::HtmlBlock => { + builder.push_div(div(), range, markdown_end); + if let Some(block) = parsed_markdown.html_blocks.get(&range.start) { + self.render_html_block(block, &mut builder, markdown_end, cx); + handled_html_block = true; + } + } MarkdownTag::List(bullet_index) => { builder.push_list(*bullet_index); builder.push_div(div().pl_2p5(), range, markdown_end); } MarkdownTag::Item => { - let bullet = if let Some((_, MarkdownEvent::TaskListMarker(checked))) = - parsed_markdown.events.get(index.saturating_add(1)) - { - let source = &parsed_markdown.source()[range.clone()]; - - Checkbox::new( - ElementId::Name(source.to_string().into()), - if *checked { + let bullet = + if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) = + parsed_markdown.events.get(index.saturating_add(1)) + { + let source = &parsed_markdown.source()[range.clone()]; + let checked = *checked; + let toggle_state = if checked { ToggleState::Selected } else { ToggleState::Unselected - }, - ) - .fill() - .visualization_only(true) - .into_any_element() - } else if let Some(bullet_index) = builder.next_bullet_index() { - div().child(format!("{}.", bullet_index)).into_any_element() - } else { - div().child("•").into_any_element() - }; - builder.push_div( - div() - .when(!self.style.height_is_multiple_of_line_height, |el| { - el.mb_1().gap_1().line_height(rems(1.3)) - }) - .h_flex() - .items_start() - .child(bullet), - range, - markdown_end, - ); - // Without `w_0`, text doesn't wrap to the width of the container. - builder.push_div(div().flex_1().w_0(), range, markdown_end); + }; + + let checkbox = Checkbox::new( + ElementId::Name(source.to_string().into()), + toggle_state, + ) + .fill(); + + if let Some(on_toggle) = self.on_checkbox_toggle.clone() { + let task_source_range = task_range.clone(); + checkbox + .on_click(move |_state, window, cx| { + on_toggle( + task_source_range.clone(), + !checked, + window, + cx, + ); + }) + .into_any_element() + } else { + checkbox.visualization_only(true).into_any_element() + } + } else if let Some(bullet_index) = builder.next_bullet_index() { + div().child(format!("{}.", bullet_index)).into_any_element() + } else { + div().child("•").into_any_element() + }; + self.push_markdown_list_item(&mut builder, bullet, range, markdown_end); } MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement { font_style: Some(FontStyle::Italic), @@ -1341,12 +1678,10 @@ impl Element for MarkdownElement { builder.pop_div(); } MarkdownTagEnd::Heading(_) => { - builder.pop_div(); - builder.pop_text_style() + self.pop_markdown_heading(&mut builder); } MarkdownTagEnd::BlockQuote(_kind) => { - builder.pop_text_style(); - builder.pop_div() + self.pop_markdown_block_quote(&mut builder); } MarkdownTagEnd::CodeBlock => { builder.trim_trailing_newline(); @@ -1424,8 +1759,7 @@ impl Element for MarkdownElement { builder.pop_div(); } MarkdownTagEnd::Item => { - builder.pop_div(); - builder.pop_div(); + self.pop_markdown_list_item(&mut builder); } MarkdownTagEnd::Emphasis => builder.pop_text_style(), MarkdownTagEnd::Strong => builder.pop_text_style(), @@ -1843,6 +2177,15 @@ impl MarkdownElementBuilder { self.div_stack.push(div); } + fn push_root_block(&mut self, range: &Range, markdown_end: usize) { + self.push_div( + div().group("markdown-root-block").relative(), + range, + markdown_end, + ); + self.push_div(div().pl_4(), range, markdown_end); + } + fn modify_current_div(&mut self, f: impl FnOnce(AnyDiv) -> AnyDiv) { self.flush_text(); if let Some(div) = self.div_stack.pop() { @@ -1850,12 +2193,53 @@ impl MarkdownElementBuilder { } } + fn pop_root_block( + &mut self, + is_active: bool, + active_gutter_color: Hsla, + hovered_gutter_color: Hsla, + ) { + self.pop_div(); + self.modify_current_div(|el| { + el.child( + div() + .h_full() + .w(px(4.0)) + .when(is_active, |this| this.bg(active_gutter_color)) + .group_hover("markdown-root-block", |this| { + if is_active { + this + } else { + this.bg(hovered_gutter_color) + } + }) + .rounded_xs() + .absolute() + .left_0() + .top_0(), + ) + }); + self.pop_div(); + } + fn pop_div(&mut self) { self.flush_text(); let div = self.div_stack.pop().unwrap().into_any_element(); self.div_stack.last_mut().unwrap().extend(iter::once(div)); } + fn push_sourced_element(&mut self, source_range: Range, element: impl Into) { + self.flush_text(); + let anchor = self.render_source_anchor(source_range); + self.div_stack.last_mut().unwrap().extend([{ + div() + .relative() + .child(anchor) + .child(element.into()) + .into_any_element() + }]); + } + fn push_list(&mut self, bullet_index: Option) { self.list_stack.push(ListStackEntry { bullet_index }); } @@ -1957,6 +2341,29 @@ impl MarkdownElementBuilder { } } + fn render_source_anchor(&mut self, source_range: Range) -> AnyElement { + let mut text_style = self.base_text_style.clone(); + text_style.color = Hsla::transparent_black(); + let text = "\u{200B}"; + let styled_text = StyledText::new(text).with_runs(vec![text_style.to_run(text.len())]); + self.rendered_lines.push(RenderedLine { + layout: styled_text.layout().clone(), + source_mappings: vec![SourceMapping { + rendered_index: 0, + source_index: source_range.start, + }], + source_end: source_range.end, + language: None, + }); + div() + .absolute() + .top_0() + .left_0() + .opacity(0.) + .child(styled_text) + .into_any_element() + } + fn flush_text(&mut self) { let line = mem::take(&mut self.pending_line); if line.text.is_empty() { @@ -2006,7 +2413,7 @@ impl RenderedLine { Ok(ix) => &self.source_mappings[ix], Err(ix) => &self.source_mappings[ix - 1], }; - mapping.rendered_index + (source_index - mapping.source_index) + (mapping.rendered_index + (source_index - mapping.source_index)).min(self.layout.len()) } fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize { @@ -2334,6 +2741,15 @@ mod tests { markdown: &str, language_registry: Option>, cx: &mut TestAppContext, + ) -> RenderedText { + render_markdown_with_options(markdown, language_registry, MarkdownOptions::default(), cx) + } + + fn render_markdown_with_options( + markdown: &str, + language_registry: Option>, + options: MarkdownOptions, + cx: &mut TestAppContext, ) -> RenderedText { struct TestWindow; @@ -2346,8 +2762,15 @@ mod tests { ensure_theme_initialized(cx); let (_, cx) = cx.add_window_view(|_, _| TestWindow); - let markdown = - cx.new(|cx| Markdown::new(markdown.to_string().into(), language_registry, None, cx)); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + markdown.to_string().into(), + language_registry, + None, + options, + cx, + ) + }); cx.run_until_parked(); let (rendered, _) = cx.draw( Default::default(), @@ -2527,7 +2950,7 @@ mod tests { #[test] fn test_table_checkbox_detection() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let (events, _, _) = crate::parser::parse_markdown(md); + let events = crate::parser::parse_markdown_with_options(md, false).events; let mut in_table = false; let mut cell_texts: Vec = Vec::new(); diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs new file mode 100644 index 0000000000000000000000000000000000000000..8b39f8c86c7b98c30c8879c362d036a333ad2c63 --- /dev/null +++ b/crates/markdown/src/mermaid.rs @@ -0,0 +1,614 @@ +use collections::HashMap; +use gpui::{ + Animation, AnimationExt, AnyElement, Context, ImageSource, RenderImage, StyledText, Task, img, + pulsating_between, +}; +use std::collections::BTreeMap; +use std::ops::Range; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use ui::prelude::*; + +use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag}; + +use super::{Markdown, MarkdownStyle, ParsedMarkdown}; + +type MermaidDiagramCache = HashMap>; + +#[derive(Clone, Debug)] +pub(crate) struct ParsedMarkdownMermaidDiagram { + pub(crate) content_range: Range, + pub(crate) contents: ParsedMarkdownMermaidDiagramContents, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ParsedMarkdownMermaidDiagramContents { + pub(crate) contents: SharedString, + pub(crate) scale: u32, +} + +#[derive(Default, Clone)] +pub(crate) struct MermaidState { + cache: MermaidDiagramCache, + order: Vec, +} + +struct CachedMermaidDiagram { + render_image: Arc>>>, + fallback_image: Option>, + _task: Task<()>, +} + +impl MermaidState { + pub(crate) fn clear(&mut self) { + self.cache.clear(); + self.order.clear(); + } + + fn get_fallback_image( + idx: usize, + old_order: &[ParsedMarkdownMermaidDiagramContents], + new_order_len: usize, + cache: &MermaidDiagramCache, + ) -> Option> { + if old_order.len() != new_order_len { + return None; + } + + old_order.get(idx).and_then(|old_content| { + cache.get(old_content).and_then(|old_cached| { + old_cached + .render_image + .get() + .and_then(|result| result.as_ref().ok().cloned()) + .or_else(|| old_cached.fallback_image.clone()) + }) + }) + } + + pub(crate) fn update(&mut self, parsed: &ParsedMarkdown, cx: &mut Context) { + let mut new_order = Vec::new(); + for mermaid_diagram in parsed.mermaid_diagrams.values() { + new_order.push(mermaid_diagram.contents.clone()); + } + + for (idx, new_content) in new_order.iter().enumerate() { + if !self.cache.contains_key(new_content) { + let fallback = + Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache); + self.cache.insert( + new_content.clone(), + Arc::new(CachedMermaidDiagram::new(new_content.clone(), fallback, cx)), + ); + } + } + + let new_order_set: std::collections::HashSet<_> = new_order.iter().cloned().collect(); + self.cache + .retain(|content, _| new_order_set.contains(content)); + self.order = new_order; + } +} + +impl CachedMermaidDiagram { + fn new( + contents: ParsedMarkdownMermaidDiagramContents, + fallback_image: Option>, + cx: &mut Context, + ) -> Self { + let render_image = Arc::new(OnceLock::>>::new()); + let render_image_clone = render_image.clone(); + let svg_renderer = cx.svg_renderer(); + + let task = cx.spawn(async move |this, cx| { + let value = cx + .background_spawn(async move { + let svg_string = mermaid_rs_renderer::render(&contents.contents)?; + let scale = contents.scale as f32 / 100.0; + svg_renderer + .render_single_frame(svg_string.as_bytes(), scale, true) + .map_err(|error| anyhow::anyhow!("{error}")) + }) + .await; + let _ = render_image_clone.set(value); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }); + + Self { + render_image, + fallback_image, + _task: task, + } + } + + #[cfg(test)] + fn new_for_test( + render_image: Option>, + fallback_image: Option>, + ) -> Self { + let result = Arc::new(OnceLock::new()); + if let Some(render_image) = render_image { + let _ = result.set(Ok(render_image)); + } + Self { + render_image: result, + fallback_image, + _task: Task::ready(()), + } + } +} + +fn parse_mermaid_info(info: &str) -> Option { + let mut parts = info.split_whitespace(); + if parts.next()? != "mermaid" { + return None; + } + + Some( + parts + .next() + .and_then(|scale| scale.parse().ok()) + .unwrap_or(100) + .clamp(10, 500), + ) +} + +pub(crate) fn extract_mermaid_diagrams( + source: &str, + events: &[(Range, MarkdownEvent)], +) -> BTreeMap { + let mut mermaid_diagrams = BTreeMap::default(); + + for (source_range, event) in events { + let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, metadata }) = event else { + continue; + }; + let CodeBlockKind::FencedLang(info) = kind else { + continue; + }; + let Some(scale) = parse_mermaid_info(info.as_ref()) else { + continue; + }; + + let contents = source[metadata.content_range.clone()] + .strip_suffix('\n') + .unwrap_or(&source[metadata.content_range.clone()]) + .to_string(); + mermaid_diagrams.insert( + source_range.start, + ParsedMarkdownMermaidDiagram { + content_range: metadata.content_range.clone(), + contents: ParsedMarkdownMermaidDiagramContents { + contents: contents.into(), + scale, + }, + }, + ); + } + + mermaid_diagrams +} + +pub(crate) fn render_mermaid_diagram( + parsed: &ParsedMarkdownMermaidDiagram, + mermaid_state: &MermaidState, + style: &MarkdownStyle, +) -> AnyElement { + let cached = mermaid_state.cache.get(&parsed.contents); + let mut container = div().w_full(); + container.style().refine(&style.code_block); + + if let Some(result) = cached.and_then(|cached| cached.render_image.get()) { + match result { + Ok(render_image) => container + .child( + div().w_full().child( + img(ImageSource::Render(render_image.clone())) + .max_w_full() + .with_fallback(|| { + div() + .child(Label::new("Failed to load mermaid diagram")) + .into_any_element() + }), + ), + ) + .into_any_element(), + Err(_) => container + .child(StyledText::new(parsed.contents.contents.clone())) + .into_any_element(), + } + } else if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) { + container + .child( + div() + .w_full() + .child( + img(ImageSource::Render(fallback.clone())) + .max_w_full() + .with_fallback(|| { + div() + .child(Label::new("Failed to load mermaid diagram")) + .into_any_element() + }), + ) + .with_animation( + "mermaid-fallback-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.6, 1.0)), + |element, delta| element.opacity(delta), + ), + ) + .into_any_element() + } else { + container + .child( + Label::new("Rendering mermaid diagram...") + .color(Color::Muted) + .with_animation( + "mermaid-loading-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::{ + CachedMermaidDiagram, MermaidDiagramCache, MermaidState, + ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info, + }; + use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle}; + use collections::HashMap; + use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size}; + use std::sync::Arc; + use ui::prelude::*; + + fn ensure_theme_initialized(cx: &mut TestAppContext) { + cx.update(|cx| { + if !cx.has_global::() { + settings::init(cx); + } + if !cx.has_global::() { + theme::init(theme::LoadThemes::JustBase, cx); + } + }); + } + + fn render_markdown_with_options( + markdown: &str, + options: MarkdownOptions, + cx: &mut TestAppContext, + ) -> crate::RenderedText { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options(markdown.to_string().into(), None, None, options, cx) + }); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + rendered.text + } + + fn mock_render_image(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + cx.svg_renderer() + .render_single_frame( + br#""#, + 1.0, + true, + ) + .unwrap() + }) + } + + fn mermaid_contents(contents: &str) -> ParsedMarkdownMermaidDiagramContents { + ParsedMarkdownMermaidDiagramContents { + contents: contents.to_string().into(), + scale: 100, + } + } + + fn mermaid_sequence(diagrams: &[&str]) -> Vec { + diagrams + .iter() + .map(|diagram| mermaid_contents(diagram)) + .collect() + } + + fn mermaid_fallback( + new_diagram: &str, + new_full_order: &[ParsedMarkdownMermaidDiagramContents], + old_full_order: &[ParsedMarkdownMermaidDiagramContents], + cache: &MermaidDiagramCache, + ) -> Option> { + let new_content = mermaid_contents(new_diagram); + let idx = new_full_order + .iter() + .position(|diagram| diagram == &new_content)?; + MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache) + } + + #[test] + fn test_parse_mermaid_info() { + assert_eq!(parse_mermaid_info("mermaid"), Some(100)); + assert_eq!(parse_mermaid_info("mermaid 150"), Some(150)); + assert_eq!(parse_mermaid_info("mermaid 5"), Some(10)); + assert_eq!(parse_mermaid_info("mermaid 999"), Some(500)); + assert_eq!(parse_mermaid_info("rust"), None); + } + + #[test] + fn test_extract_mermaid_diagrams_parses_scale() { + let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```"; + let events = crate::parser::parse_markdown_with_options(markdown, false).events; + let diagrams = extract_mermaid_diagrams(markdown, &events); + + assert_eq!(diagrams.len(), 1); + let diagram = diagrams.values().next().unwrap(); + assert_eq!(diagram.contents.contents, "graph TD;"); + assert_eq!(diagram.contents.scale, 150); + } + + #[gpui::test] + fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); + + let svg_b = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph B"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(svg_b.clone()), + None, + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = + mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache); + + assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_b.id)); + } + + #[gpui::test] + fn test_mermaid_no_fallback_on_add_in_middle(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache); + + assert!(fallback.is_none()); + } + + #[gpui::test] + fn test_mermaid_fallback_chains_on_rapid_edits(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]); + + let original_svg = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph B modified"), + Arc::new(CachedMermaidDiagram::new_for_test( + None, + Some(original_svg.clone()), + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback( + "graph B modified again", + &new_full_order, + &old_full_order, + &cache, + ); + + assert_eq!( + fallback.as_ref().map(|image| image.id), + Some(original_svg.id) + ); + } + + #[gpui::test] + fn test_mermaid_fallback_with_duplicate_blocks_edit_second(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); + let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]); + + let svg_a = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(svg_a.clone()), + None, + )), + ); + cache.insert( + mermaid_contents("graph B"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); + + assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_a.id)); + } + + #[gpui::test] + fn test_mermaid_rendering_replaces_code_block_text(cx: &mut TestAppContext) { + let rendered = render_markdown_with_options( + "```mermaid\ngraph TD;\n```", + MarkdownOptions { + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ); + + let text = rendered + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>() + .join("\n"); + + assert!(!text.contains("graph TD;")); + } + + #[gpui::test] + fn test_mermaid_source_anchor_maps_inside_block(cx: &mut TestAppContext) { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + "```mermaid\ngraph TD;\n```".into(), + None, + None, + MarkdownOptions { + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ) + }); + cx.run_until_parked(); + let render_image = mock_render_image(cx); + markdown.update(cx, |markdown, _| { + let contents = markdown + .parsed_markdown + .mermaid_diagrams + .values() + .next() + .unwrap() + .contents + .clone(); + markdown.mermaid_state.cache.insert( + contents.clone(), + Arc::new(CachedMermaidDiagram::new_for_test(Some(render_image), None)), + ); + markdown.mermaid_state.order = vec![contents]; + }); + + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown.clone(), MarkdownStyle::default()) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) + }, + ); + + let mermaid_diagram = markdown.update(cx, |markdown, _| { + markdown + .parsed_markdown + .mermaid_diagrams + .values() + .next() + .unwrap() + .clone() + }); + assert!( + rendered + .text + .position_for_source_index(mermaid_diagram.content_range.start) + .is_some() + ); + assert!( + rendered + .text + .position_for_source_index(mermaid_diagram.content_range.end.saturating_sub(1)) + .is_some() + ); + } +} diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index f530b88908380be13de2005bb8b3ec2b7e6e31b5..2c0ca0cdd2e3f383342be5457d127ce7112e330e 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -4,11 +4,11 @@ pub use pulldown_cmark::TagEnd as MarkdownTagEnd; use pulldown_cmark::{ Alignment, CowStr, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser, }; -use std::{ops::Range, sync::Arc}; +use std::{collections::BTreeMap, ops::Range, sync::Arc}; use collections::HashSet; -use crate::path_range::PathWithRange; +use crate::{html, path_range::PathWithRange}; pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES .union(Options::ENABLE_FOOTNOTES) @@ -22,16 +22,69 @@ pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES .union(Options::ENABLE_SUPERSCRIPT) .union(Options::ENABLE_SUBSCRIPT); -pub fn parse_markdown( - text: &str, -) -> ( - Vec<(Range, MarkdownEvent)>, - HashSet, - HashSet>, -) { - let mut events = Vec::new(); +#[derive(Default)] +struct ParseState { + events: Vec<(Range, MarkdownEvent)>, + root_block_starts: Vec, + depth: usize, +} + +#[derive(Debug, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedMarkdownData { + pub events: Vec<(Range, MarkdownEvent)>, + pub language_names: HashSet, + pub language_paths: HashSet>, + pub root_block_starts: Vec, + pub html_blocks: BTreeMap, +} + +impl ParseState { + fn push_event(&mut self, range: Range, event: MarkdownEvent) { + match &event { + MarkdownEvent::Start(_) => { + if self.depth == 0 { + self.root_block_starts.push(range.start); + self.events.push((range.clone(), MarkdownEvent::RootStart)); + } + self.depth += 1; + self.events.push((range, event)); + } + MarkdownEvent::End(_) => { + self.events.push((range.clone(), event)); + if self.depth > 0 { + self.depth -= 1; + if self.depth == 0 { + let root_block_index = self.root_block_starts.len() - 1; + self.events + .push((range, MarkdownEvent::RootEnd(root_block_index))); + } + } + } + MarkdownEvent::Rule => { + if self.depth == 0 && !range.is_empty() { + self.root_block_starts.push(range.start); + let root_block_index = self.root_block_starts.len() - 1; + self.events.push((range.clone(), MarkdownEvent::RootStart)); + self.events.push((range.clone(), event)); + self.events + .push((range, MarkdownEvent::RootEnd(root_block_index))); + } else { + self.events.push((range, event)); + } + } + _ => { + self.events.push((range, event)); + } + } + } +} + +pub(crate) fn parse_markdown_with_options(text: &str, parse_html: bool) -> ParsedMarkdownData { + let mut state = ParseState::default(); let mut language_names = HashSet::default(); let mut language_paths = HashSet::default(); + let mut html_blocks = BTreeMap::default(); let mut within_link = false; let mut within_metadata = false; let mut parser = Parser::new_ext(text, PARSE_OPTIONS) @@ -48,6 +101,32 @@ pub fn parse_markdown( } match pulldown_event { pulldown_cmark::Event::Start(tag) => { + if let pulldown_cmark::Tag::HtmlBlock = &tag { + state.push_event(range.clone(), MarkdownEvent::Start(MarkdownTag::HtmlBlock)); + + if parse_html { + if let Some(block) = + html::html_parser::parse_html_block(&text[range.clone()], range.clone()) + { + html_blocks.insert(range.start, block); + + while let Some((event, end_range)) = parser.next() { + if let pulldown_cmark::Event::End( + pulldown_cmark::TagEnd::HtmlBlock, + ) = event + { + state.push_event( + end_range, + MarkdownEvent::End(MarkdownTagEnd::HtmlBlock), + ); + break; + } + } + } + } + continue; + } + let tag = match tag { pulldown_cmark::Tag::Link { link_type, @@ -63,9 +142,9 @@ pub fn parse_markdown( id: SharedString::from(id.into_string()), } } - pulldown_cmark::Tag::MetadataBlock(kind) => { + pulldown_cmark::Tag::MetadataBlock(_kind) => { within_metadata = true; - MarkdownTag::MetadataBlock(kind) + continue; } pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => { MarkdownTag::CodeBlock { @@ -164,20 +243,20 @@ pub fn parse_markdown( title: SharedString::from(title.into_string()), id: SharedString::from(id.into_string()), }, - pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, + pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, // this is handled above separately pulldown_cmark::Tag::DefinitionList => MarkdownTag::DefinitionList, pulldown_cmark::Tag::DefinitionListTitle => MarkdownTag::DefinitionListTitle, pulldown_cmark::Tag::DefinitionListDefinition => { MarkdownTag::DefinitionListDefinition } }; - events.push((range, MarkdownEvent::Start(tag))) + state.push_event(range, MarkdownEvent::Start(tag)) } pulldown_cmark::Event::End(tag) => { if let pulldown_cmark::TagEnd::Link = tag { within_link = false; } - events.push((range, MarkdownEvent::End(tag))); + state.push_event(range, MarkdownEvent::End(tag)); } pulldown_cmark::Event::Text(parsed) => { fn event_for( @@ -205,16 +284,26 @@ pub fn parse_markdown( parsed, }]; - while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _))) { - let Some((pulldown_cmark::Event::Text(next_event), next_range)) = parser.next() - else { + while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _))) + || (parse_html + && matches!( + parser.peek(), + Some((pulldown_cmark::Event::InlineHtml(_), _)) + )) + { + let Some((next_event, next_range)) = parser.next() else { unreachable!() }; - let next_len = last_len + next_event.len(); + let next_text = match next_event { + pulldown_cmark::Event::Text(next_event) => next_event, + pulldown_cmark::Event::InlineHtml(_) => CowStr::Borrowed(""), + _ => unreachable!(), + }; + let next_len = last_len + next_text.len(); ranges.push(TextRange { source_range: next_range.clone(), merged_range: last_len..next_len, - parsed: next_event, + parsed: next_text, }); last_len = next_len; } @@ -241,7 +330,8 @@ pub fn parse_markdown( .is_some_and(|range| range.merged_range.end <= link_start_in_merged) { let range = ranges.next().unwrap(); - events.push(event_for(text, range.source_range, &range.parsed)); + let (range, event) = event_for(text, range.source_range, &range.parsed); + state.push_event(range, event); } let Some(range) = ranges.peek_mut() else { @@ -250,11 +340,12 @@ pub fn parse_markdown( let prefix_len = link_start_in_merged - range.merged_range.start; if prefix_len > 0 { let (head, tail) = range.parsed.split_at(prefix_len); - events.push(event_for( + let (event_range, event) = event_for( text, range.source_range.start..range.source_range.start + prefix_len, head, - )); + ); + state.push_event(event_range, event); range.parsed = CowStr::Boxed(tail.into()); range.merged_range.start += prefix_len; range.source_range.start += prefix_len; @@ -290,7 +381,7 @@ pub fn parse_markdown( } let link_range = link_start_in_source..link_end_in_source; - events.push(( + state.push_event( link_range.clone(), MarkdownEvent::Start(MarkdownTag::Link { link_type: LinkType::Autolink, @@ -298,37 +389,52 @@ pub fn parse_markdown( title: SharedString::default(), id: SharedString::default(), }), - )); - events.extend(link_events); - events.push((link_range.clone(), MarkdownEvent::End(MarkdownTagEnd::Link))); + ); + for (range, event) in link_events { + state.push_event(range, event); + } + state.push_event( + link_range.clone(), + MarkdownEvent::End(MarkdownTagEnd::Link), + ); } } for range in ranges { - events.push(event_for(text, range.source_range, &range.parsed)); + let (range, event) = event_for(text, range.source_range, &range.parsed); + state.push_event(range, event); } } pulldown_cmark::Event::Code(_) => { let content_range = extract_code_content_range(&text[range.clone()]); let content_range = content_range.start + range.start..content_range.end + range.start; - events.push((content_range, MarkdownEvent::Code)) + state.push_event(content_range, MarkdownEvent::Code) + } + pulldown_cmark::Event::Html(_) => state.push_event(range, MarkdownEvent::Html), + pulldown_cmark::Event::InlineHtml(_) => { + state.push_event(range, MarkdownEvent::InlineHtml) } - pulldown_cmark::Event::Html(_) => events.push((range, MarkdownEvent::Html)), - pulldown_cmark::Event::InlineHtml(_) => events.push((range, MarkdownEvent::InlineHtml)), pulldown_cmark::Event::FootnoteReference(_) => { - events.push((range, MarkdownEvent::FootnoteReference)) + state.push_event(range, MarkdownEvent::FootnoteReference) } - pulldown_cmark::Event::SoftBreak => events.push((range, MarkdownEvent::SoftBreak)), - pulldown_cmark::Event::HardBreak => events.push((range, MarkdownEvent::HardBreak)), - pulldown_cmark::Event::Rule => events.push((range, MarkdownEvent::Rule)), + pulldown_cmark::Event::SoftBreak => state.push_event(range, MarkdownEvent::SoftBreak), + pulldown_cmark::Event::HardBreak => state.push_event(range, MarkdownEvent::HardBreak), + pulldown_cmark::Event::Rule => state.push_event(range, MarkdownEvent::Rule), pulldown_cmark::Event::TaskListMarker(checked) => { - events.push((range, MarkdownEvent::TaskListMarker(checked))) + state.push_event(range, MarkdownEvent::TaskListMarker(checked)) } pulldown_cmark::Event::InlineMath(_) | pulldown_cmark::Event::DisplayMath(_) => {} } } - (events, language_names, language_paths) + + ParsedMarkdownData { + events: state.events, + language_names, + language_paths, + root_block_starts: state.root_block_starts, + html_blocks, + } } pub fn parse_links_only(text: &str) -> Vec<(Range, MarkdownEvent)> { @@ -401,6 +507,10 @@ pub enum MarkdownEvent { Rule, /// A task list marker, rendered as a checkbox in HTML. Contains a true when it is checked. TaskListMarker(bool), + /// Start of a root-level block (a top-level structural element like a paragraph, heading, list, etc.). + RootStart, + /// End of a root-level block. Contains the root block index. + RootEnd(usize), } /// Tags for elements that can contain other elements. @@ -575,31 +685,39 @@ mod tests { #[test] fn test_html_comments() { assert_eq!( - parse_markdown(" \nReturns"), - ( - vec![ + parse_markdown_with_options(" \nReturns", false), + ParsedMarkdownData { + events: vec![ + (2..30, RootStart), (2..30, Start(HtmlBlock)), (2..2, SubstitutedText(" ".into())), (2..7, Html), (7..26, Html), (26..30, Html), (2..30, End(MarkdownTagEnd::HtmlBlock)), + (2..30, RootEnd(0)), + (30..37, RootStart), (30..37, Start(Paragraph)), (30..37, Text), - (30..37, End(MarkdownTagEnd::Paragraph)) + (30..37, End(MarkdownTagEnd::Paragraph)), + (30..37, RootEnd(1)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![2, 30], + ..Default::default() + } ) } #[test] fn test_plain_urls_and_escaped_text() { assert_eq!( - parse_markdown("   https://some.url some \\`►\\` text"), - ( - vec![ + parse_markdown_with_options( + "   https://some.url some \\`►\\` text", + false + ), + ParsedMarkdownData { + events: vec![ + (0..51, RootStart), (0..51, Start(Paragraph)), (0..6, SubstitutedText("\u{a0}".into())), (6..12, SubstitutedText("\u{a0}".into())), @@ -620,19 +738,25 @@ mod tests { (37..44, SubstitutedText("►".into())), (45..46, Text), // Escaped backtick (46..51, Text), - (0..51, End(MarkdownTagEnd::Paragraph)) + (0..51, End(MarkdownTagEnd::Paragraph)), + (0..51, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ); } #[test] fn test_incomplete_link() { assert_eq!( - parse_markdown("You can use the [GitHub Search API](https://docs.github.com/en").0, + parse_markdown_with_options( + "You can use the [GitHub Search API](https://docs.github.com/en", + false + ) + .events, vec![ + (0..62, RootStart), (0..62, Start(Paragraph)), (0..16, Text), (16..17, Text), @@ -650,7 +774,8 @@ mod tests { ), (36..62, Text), (36..62, End(MarkdownTagEnd::Link)), - (0..62, End(MarkdownTagEnd::Paragraph)) + (0..62, End(MarkdownTagEnd::Paragraph)), + (0..62, RootEnd(0)), ], ); } @@ -658,9 +783,13 @@ mod tests { #[test] fn test_smart_punctuation() { assert_eq!( - parse_markdown("-- --- ... \"double quoted\" 'single quoted' ----------"), - ( - vec![ + parse_markdown_with_options( + "-- --- ... \"double quoted\" 'single quoted' ----------", + false + ), + ParsedMarkdownData { + events: vec![ + (0..53, RootStart), (0..53, Start(Paragraph)), (0..2, SubstitutedText("–".into())), (2..3, Text), @@ -668,29 +797,31 @@ mod tests { (6..7, Text), (7..10, SubstitutedText("…".into())), (10..11, Text), - (11..12, SubstitutedText("“".into())), + (11..12, SubstitutedText("\u{201c}".into())), (12..25, Text), - (25..26, SubstitutedText("”".into())), + (25..26, SubstitutedText("\u{201d}".into())), (26..27, Text), - (27..28, SubstitutedText("‘".into())), + (27..28, SubstitutedText("\u{2018}".into())), (28..41, Text), - (41..42, SubstitutedText("’".into())), + (41..42, SubstitutedText("\u{2019}".into())), (42..43, Text), (43..53, SubstitutedText("–––––".into())), - (0..53, End(MarkdownTagEnd::Paragraph)) + (0..53, End(MarkdownTagEnd::Paragraph)), + (0..53, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ) } #[test] fn test_code_block_metadata() { assert_eq!( - parse_markdown("```rust\nfn main() {\n let a = 1;\n}\n```"), - ( - vec![ + parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false), + ParsedMarkdownData { + events: vec![ + (0..37, RootStart), ( 0..37, Start(CodeBlock { @@ -703,19 +834,22 @@ mod tests { ), (8..34, Text), (0..37, End(MarkdownTagEnd::CodeBlock)), + (0..37, RootEnd(0)), ], - { + language_names: { let mut h = HashSet::default(); h.insert("rust".into()); h }, - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ); assert_eq!( - parse_markdown(" fn main() {}"), - ( - vec![ + parse_markdown_with_options(" fn main() {}", false), + ParsedMarkdownData { + events: vec![ + (4..16, RootStart), ( 4..16, Start(CodeBlock { @@ -727,14 +861,76 @@ mod tests { }) ), (4..16, Text), - (4..16, End(MarkdownTagEnd::CodeBlock)) + (4..16, End(MarkdownTagEnd::CodeBlock)), + (4..16, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![4], + ..Default::default() + } ); } + #[test] + fn test_metadata_blocks_do_not_affect_root_blocks() { + assert_eq!( + parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false), + ParsedMarkdownData { + events: vec![ + (27..36, RootStart), + (27..36, Start(Paragraph)), + (27..36, Text), + (27..36, End(MarkdownTagEnd::Paragraph)), + (27..36, RootEnd(0)), + ], + root_block_starts: vec![27], + ..Default::default() + } + ); + } + + #[test] + fn test_table_checkboxes_remain_text_in_cells() { + let markdown = "\ +| Done | Task | +|------|---------| +| [x] | Fix bug | +| [ ] | Add feature |"; + let parsed = parse_markdown_with_options(markdown, false); + + let mut in_table = false; + let mut saw_task_list_marker = false; + let mut cell_texts = Vec::new(); + let mut current_cell = String::new(); + + for (range, event) in &parsed.events { + match event { + Start(Table(_)) => in_table = true, + End(MarkdownTagEnd::Table) => in_table = false, + Start(TableCell) => current_cell.clear(), + End(MarkdownTagEnd::TableCell) => { + if in_table { + cell_texts.push(current_cell.clone()); + } + } + Text if in_table => current_cell.push_str(&markdown[range.clone()]), + TaskListMarker(_) if in_table => saw_task_list_marker = true, + _ => {} + } + } + + let checkbox_cells: Vec<&str> = cell_texts + .iter() + .map(|cell| cell.trim()) + .filter(|cell| *cell == "[x]" || *cell == "[X]" || *cell == "[ ]") + .collect(); + + assert!( + !saw_task_list_marker, + "Table checkboxes should remain text, not task-list markers" + ); + assert_eq!(checkbox_cells, vec!["[x]", "[ ]"]); + } + #[test] fn test_extract_code_content_range() { let input = "```let x = 5;```"; @@ -776,8 +972,13 @@ mod tests { // Note: In real usage, pulldown_cmark creates separate text events for the escaped character // We're verifying our parser can handle this correctly assert_eq!( - parse_markdown("https:/\\/example.com is equivalent to https://example.com!").0, + parse_markdown_with_options( + "https:/\\/example.com is equivalent to https://example.com!", + false + ) + .events, vec![ + (0..62, RootStart), (0..62, Start(Paragraph)), ( 0..20, @@ -806,13 +1007,19 @@ mod tests { (58..61, Text), (38..61, End(MarkdownTagEnd::Link)), (61..62, Text), - (0..62, End(MarkdownTagEnd::Paragraph)) + (0..62, End(MarkdownTagEnd::Paragraph)), + (0..62, RootEnd(0)), ], ); assert_eq!( - parse_markdown("Visit https://example.com/cat\\/é‍☕ for coffee!").0, + parse_markdown_with_options( + "Visit https://example.com/cat\\/é‍☕ for coffee!", + false + ) + .events, [ + (0..55, RootStart), (0..55, Start(Paragraph)), (0..6, Text), ( @@ -830,7 +1037,8 @@ mod tests { (40..43, Text), (6..43, End(MarkdownTagEnd::Link)), (43..55, Text), - (0..55, End(MarkdownTagEnd::Paragraph)) + (0..55, End(MarkdownTagEnd::Paragraph)), + (0..55, RootEnd(0)), ] ); } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 782de627ec26273820bb3505b778a862659f315f..558b57b769953b572678c3d997ae771462f51896 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -16,28 +16,18 @@ test-support = [] [dependencies] anyhow.workspace = true -async-recursion.workspace = true -collections.workspace = true editor.workspace = true gpui.workspace = true -html5ever.workspace = true language.workspace = true -linkify.workspace = true log.workspace = true markdown.workspace = true -markup5ever_rcdom.workspace = true -pretty_assertions.workspace = true -pulldown-cmark.workspace = true settings.workspace = true -stacksafe.workspace = true theme.workspace = true ui.workspace = true urlencoding.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -mermaid-rs-renderer.workspace = true [dev-dependencies] -editor = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } +tempfile.workspace = true diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs deleted file mode 100644 index e8d9fd0ab8e3ea8583548f5abb3168e07119a4d9..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_elements.rs +++ /dev/null @@ -1,374 +0,0 @@ -use gpui::{ - DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, - UnderlineStyle, px, -}; -use language::HighlightId; - -use std::{fmt::Display, ops::Range, path::PathBuf}; -use urlencoding; - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownElement { - Heading(ParsedMarkdownHeading), - ListItem(ParsedMarkdownListItem), - Table(ParsedMarkdownTable), - BlockQuote(ParsedMarkdownBlockQuote), - CodeBlock(ParsedMarkdownCodeBlock), - MermaidDiagram(ParsedMarkdownMermaidDiagram), - /// A paragraph of text and other inline elements. - Paragraph(MarkdownParagraph), - HorizontalRule(Range), - Image(Image), -} - -impl ParsedMarkdownElement { - pub fn source_range(&self) -> Option> { - Some(match self { - Self::Heading(heading) => heading.source_range.clone(), - Self::ListItem(list_item) => list_item.source_range.clone(), - Self::Table(table) => table.source_range.clone(), - Self::BlockQuote(block_quote) => block_quote.source_range.clone(), - Self::CodeBlock(code_block) => code_block.source_range.clone(), - Self::MermaidDiagram(mermaid) => mermaid.source_range.clone(), - Self::Paragraph(text) => match text.get(0)? { - MarkdownParagraphChunk::Text(t) => t.source_range.clone(), - MarkdownParagraphChunk::Image(image) => image.source_range.clone(), - }, - Self::HorizontalRule(range) => range.clone(), - Self::Image(image) => image.source_range.clone(), - }) - } - - pub fn is_list_item(&self) -> bool { - matches!(self, Self::ListItem(_)) - } -} - -pub type MarkdownParagraph = Vec; - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum MarkdownParagraphChunk { - Text(ParsedMarkdownText), - Image(Image), -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdown { - pub children: Vec, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownListItem { - pub source_range: Range, - /// How many indentations deep this item is. - pub depth: u16, - pub item_type: ParsedMarkdownListItemType, - pub content: Vec, - /// Whether we can expect nested list items inside of this items `content`. - pub nested: bool, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownListItemType { - Ordered(u64), - Task(bool, Range), - Unordered, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownCodeBlock { - pub source_range: Range, - pub language: Option, - pub contents: SharedString, - pub highlights: Option, HighlightId)>>, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownMermaidDiagram { - pub source_range: Range, - pub contents: ParsedMarkdownMermaidDiagramContents, -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ParsedMarkdownMermaidDiagramContents { - pub contents: SharedString, - pub scale: u32, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownHeading { - pub source_range: Range, - pub level: HeadingLevel, - pub contents: MarkdownParagraph, -} - -#[derive(Debug, PartialEq)] -pub enum HeadingLevel { - H1, - H2, - H3, - H4, - H5, - H6, -} - -#[derive(Debug)] -pub struct ParsedMarkdownTable { - pub source_range: Range, - pub header: Vec, - pub body: Vec, - pub caption: Option, -} - -#[derive(Debug, Clone, Copy, Default)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownTableAlignment { - #[default] - None, - Left, - Center, - Right, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownTableColumn { - pub col_span: usize, - pub row_span: usize, - pub is_header: bool, - pub children: MarkdownParagraph, - pub alignment: ParsedMarkdownTableAlignment, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownTableRow { - pub columns: Vec, -} - -impl Default for ParsedMarkdownTableRow { - fn default() -> Self { - Self::new() - } -} - -impl ParsedMarkdownTableRow { - pub fn new() -> Self { - Self { - columns: Vec::new(), - } - } - - pub fn with_columns(columns: Vec) -> Self { - Self { columns } - } -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownBlockQuote { - pub source_range: Range, - pub children: Vec, -} - -#[derive(Debug, Clone)] -pub struct ParsedMarkdownText { - /// Where the text is located in the source Markdown document. - pub source_range: Range, - /// The text content stripped of any formatting symbols. - pub contents: SharedString, - /// The list of highlights contained in the Markdown document. - pub highlights: Vec<(Range, MarkdownHighlight)>, - /// The regions of the Markdown document. - pub regions: Vec<(Range, ParsedRegion)>, -} - -/// A run of highlighted Markdown text. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MarkdownHighlight { - /// A styled Markdown highlight. - Style(MarkdownHighlightStyle), - /// A highlighted code block. - Code(HighlightId), -} - -impl MarkdownHighlight { - /// Converts this [`MarkdownHighlight`] to a [`HighlightStyle`]. - pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option { - match self { - MarkdownHighlight::Style(style) => { - let mut highlight = HighlightStyle::default(); - - if style.italic { - highlight.font_style = Some(FontStyle::Italic); - } - - if style.underline { - highlight.underline = Some(UnderlineStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.strikethrough { - highlight.strikethrough = Some(StrikethroughStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.weight != FontWeight::default() { - highlight.font_weight = Some(style.weight); - } - - if style.link { - highlight.underline = Some(UnderlineStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.oblique { - highlight.font_style = Some(FontStyle::Oblique) - } - - Some(highlight) - } - - MarkdownHighlight::Code(id) => theme.get(*id).cloned(), - } - } -} - -/// The style for a Markdown highlight. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct MarkdownHighlightStyle { - /// Whether the text should be italicized. - pub italic: bool, - /// Whether the text should be underlined. - pub underline: bool, - /// Whether the text should be struck through. - pub strikethrough: bool, - /// The weight of the text. - pub weight: FontWeight, - /// Whether the text should be stylized as link. - pub link: bool, - // Whether the text should be obliqued. - pub oblique: bool, -} - -/// A parsed region in a Markdown document. -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedRegion { - /// Whether the region is a code block. - pub code: bool, - /// The link contained in this region, if it has one. - pub link: Option, -} - -/// A Markdown link. -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub enum Link { - /// A link to a webpage. - Web { - /// The URL of the webpage. - url: String, - }, - /// A link to a path on the filesystem. - Path { - /// The path as provided in the Markdown document. - display_path: PathBuf, - /// The absolute path to the item. - path: PathBuf, - }, -} - -impl Link { - pub fn identify(file_location_directory: Option, text: String) -> Option { - if text.starts_with("http") { - return Some(Link::Web { url: text }); - } - - // URL decode the text to handle spaces and other special characters - let decoded_text = urlencoding::decode(&text) - .map(|s| s.into_owned()) - .unwrap_or(text); - - let path = PathBuf::from(&decoded_text); - if path.is_absolute() && path.exists() { - return Some(Link::Path { - display_path: path.clone(), - path, - }); - } - - if let Some(file_location_directory) = file_location_directory { - let display_path = path; - let path = file_location_directory.join(decoded_text); - if path.exists() { - return Some(Link::Path { display_path, path }); - } - } - - None - } -} - -impl Display for Link { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Link::Web { url } => write!(f, "{}", url), - Link::Path { display_path, .. } => write!(f, "{}", display_path.display()), - } - } -} - -/// A Markdown Image -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub struct Image { - pub link: Link, - pub source_range: Range, - pub alt_text: Option, - pub width: Option, - pub height: Option, -} - -impl Image { - pub fn identify( - text: String, - source_range: Range, - file_location_directory: Option, - ) -> Option { - let link = Link::identify(file_location_directory, text)?; - Some(Self { - source_range, - link, - alt_text: None, - width: None, - height: None, - }) - } - - pub fn set_alt_text(&mut self, alt_text: SharedString) { - self.alt_text = Some(alt_text); - } - - pub fn set_width(&mut self, width: DefiniteLength) { - self.width = Some(width); - } - - pub fn set_height(&mut self, height: DefiniteLength) { - self.height = Some(height); - } -} diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs deleted file mode 100644 index 40a1ed804f750a7e3173a76643ad1f6b1a362bd3..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_parser.rs +++ /dev/null @@ -1,3320 +0,0 @@ -use crate::{ - markdown_elements::*, - markdown_minifier::{Minifier, MinifierOptions}, -}; -use async_recursion::async_recursion; -use collections::FxHashMap; -use gpui::{DefiniteLength, FontWeight, px, relative}; -use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink}; -use language::LanguageRegistry; -use markdown::parser::PARSE_OPTIONS; -use markup5ever_rcdom::RcDom; -use pulldown_cmark::{Alignment, Event, Parser, Tag, TagEnd}; -use stacksafe::stacksafe; -use std::{ - cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec, -}; -use ui::SharedString; - -pub async fn parse_markdown( - markdown_input: &str, - file_location_directory: Option, - language_registry: Option>, -) -> ParsedMarkdown { - let parser = Parser::new_ext(markdown_input, PARSE_OPTIONS); - let parser = MarkdownParser::new( - parser.into_offset_iter().collect(), - file_location_directory, - language_registry, - ); - let renderer = parser.parse_document().await; - ParsedMarkdown { - children: renderer.parsed, - } -} - -fn cleanup_html(source: &str) -> Vec { - let mut writer = std::io::Cursor::new(Vec::new()); - let mut reader = std::io::Cursor::new(source); - let mut minify = Minifier::new( - &mut writer, - MinifierOptions { - omit_doctype: true, - collapse_whitespace: true, - ..Default::default() - }, - ); - if let Ok(()) = minify.minify(&mut reader) { - writer.into_inner() - } else { - source.bytes().collect() - } -} - -struct MarkdownParser<'a> { - tokens: Vec<(Event<'a>, Range)>, - /// The current index in the tokens array - cursor: usize, - /// The blocks that we have successfully parsed so far - parsed: Vec, - file_location_directory: Option, - language_registry: Option>, -} - -#[derive(Debug)] -struct ParseHtmlNodeContext { - list_item_depth: u16, -} - -impl Default for ParseHtmlNodeContext { - fn default() -> Self { - Self { list_item_depth: 1 } - } -} - -struct MarkdownListItem { - content: Vec, - item_type: ParsedMarkdownListItemType, -} - -impl Default for MarkdownListItem { - fn default() -> Self { - Self { - content: Vec::new(), - item_type: ParsedMarkdownListItemType::Unordered, - } - } -} - -impl<'a> MarkdownParser<'a> { - fn new( - tokens: Vec<(Event<'a>, Range)>, - file_location_directory: Option, - language_registry: Option>, - ) -> Self { - Self { - tokens, - file_location_directory, - language_registry, - cursor: 0, - parsed: vec![], - } - } - - fn eof(&self) -> bool { - if self.tokens.is_empty() { - return true; - } - self.cursor >= self.tokens.len() - 1 - } - - fn peek(&self, steps: usize) -> Option<&(Event<'_>, Range)> { - if self.eof() || (steps + self.cursor) >= self.tokens.len() { - return self.tokens.last(); - } - self.tokens.get(self.cursor + steps) - } - - fn previous(&self) -> Option<&(Event<'_>, Range)> { - if self.cursor == 0 || self.cursor > self.tokens.len() { - return None; - } - self.tokens.get(self.cursor - 1) - } - - fn current(&self) -> Option<&(Event<'_>, Range)> { - self.peek(0) - } - - fn current_event(&self) -> Option<&Event<'_>> { - self.current().map(|(event, _)| event) - } - - fn is_text_like(event: &Event) -> bool { - match event { - Event::Text(_) - // Represent an inline code block - | Event::Code(_) - | Event::Html(_) - | Event::InlineHtml(_) - | Event::FootnoteReference(_) - | Event::Start(Tag::Link { .. }) - | Event::Start(Tag::Emphasis) - | Event::Start(Tag::Strong) - | Event::Start(Tag::Strikethrough) - | Event::Start(Tag::Image { .. }) => { - true - } - _ => false, - } - } - - async fn parse_document(mut self) -> Self { - while !self.eof() { - if let Some(block) = self.parse_block().await { - self.parsed.extend(block); - } else { - self.cursor += 1; - } - } - self - } - - #[async_recursion] - async fn parse_block(&mut self) -> Option> { - let (current, source_range) = self.current().unwrap(); - let source_range = source_range.clone(); - match current { - Event::Start(tag) => match tag { - Tag::Paragraph => { - self.cursor += 1; - let text = self.parse_text(false, Some(source_range)); - Some(vec![ParsedMarkdownElement::Paragraph(text)]) - } - Tag::Heading { level, .. } => { - let level = *level; - self.cursor += 1; - let heading = self.parse_heading(level); - Some(vec![ParsedMarkdownElement::Heading(heading)]) - } - Tag::Table(alignment) => { - let alignment = alignment.clone(); - self.cursor += 1; - let table = self.parse_table(alignment); - Some(vec![ParsedMarkdownElement::Table(table)]) - } - Tag::List(order) => { - let order = *order; - self.cursor += 1; - let list = self.parse_list(order).await; - Some(list) - } - Tag::BlockQuote(_kind) => { - self.cursor += 1; - let block_quote = self.parse_block_quote().await; - Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)]) - } - Tag::CodeBlock(kind) => { - let (language, scale) = match kind { - pulldown_cmark::CodeBlockKind::Indented => (None, None), - pulldown_cmark::CodeBlockKind::Fenced(language) => { - if language.is_empty() { - (None, None) - } else { - let parts: Vec<&str> = language.split_whitespace().collect(); - let lang = parts.first().map(|s| s.to_string()); - let scale = parts.get(1).and_then(|s| s.parse::().ok()); - (lang, scale) - } - } - }; - - self.cursor += 1; - - if language.as_deref() == Some("mermaid") { - let mermaid_diagram = self.parse_mermaid_diagram(scale).await?; - Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)]) - } else { - let code_block = self.parse_code_block(language).await?; - Some(vec![ParsedMarkdownElement::CodeBlock(code_block)]) - } - } - Tag::HtmlBlock => { - self.cursor += 1; - - Some(self.parse_html_block().await) - } - _ => None, - }, - Event::Rule => { - self.cursor += 1; - Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)]) - } - _ => None, - } - } - - fn parse_text( - &mut self, - should_complete_on_soft_break: bool, - source_range: Option>, - ) -> MarkdownParagraph { - let source_range = source_range.unwrap_or_else(|| { - self.current() - .map(|(_, range)| range.clone()) - .unwrap_or_default() - }); - - let mut markdown_text_like = Vec::new(); - let mut text = String::new(); - let mut bold_depth = 0; - let mut italic_depth = 0; - let mut strikethrough_depth = 0; - let mut link: Option = None; - let mut image: Option = None; - let mut regions: Vec<(Range, ParsedRegion)> = vec![]; - let mut highlights: Vec<(Range, MarkdownHighlight)> = vec![]; - let mut link_urls: Vec = vec![]; - let mut link_ranges: Vec> = vec![]; - - loop { - if self.eof() { - break; - } - - let (current, _) = self.current().unwrap(); - let prev_len = text.len(); - match current { - Event::SoftBreak => { - if should_complete_on_soft_break { - break; - } - text.push(' '); - } - - Event::HardBreak => { - text.push('\n'); - } - - // We want to ignore any inline HTML tags in the text but keep - // the text between them - Event::InlineHtml(_) => {} - - Event::Text(t) => { - text.push_str(t.as_ref()); - let mut style = MarkdownHighlightStyle::default(); - - if bold_depth > 0 { - style.weight = FontWeight::BOLD; - } - - if italic_depth > 0 { - style.italic = true; - } - - if strikethrough_depth > 0 { - style.strikethrough = true; - } - - let last_run_len = if let Some(link) = link.clone() { - regions.push(( - prev_len..text.len(), - ParsedRegion { - code: false, - link: Some(link), - }, - )); - style.link = true; - prev_len - } else { - // Manually scan for links - let mut finder = linkify::LinkFinder::new(); - finder.kinds(&[linkify::LinkKind::Url]); - let mut last_link_len = prev_len; - for link in finder.links(t) { - let start = prev_len + link.start(); - let end = prev_len + link.end(); - let range = start..end; - link_ranges.push(range.clone()); - link_urls.push(link.as_str().to_string()); - - // If there is a style before we match a link, we have to add this to the highlighted ranges - if style != MarkdownHighlightStyle::default() && last_link_len < start { - highlights.push(( - last_link_len..start, - MarkdownHighlight::Style(style.clone()), - )); - } - - highlights.push(( - range.clone(), - MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - ..style - }), - )); - - regions.push(( - range.clone(), - ParsedRegion { - code: false, - link: Some(Link::Web { - url: link.as_str().to_string(), - }), - }, - )); - last_link_len = end; - } - last_link_len - }; - - if style != MarkdownHighlightStyle::default() && last_run_len < text.len() { - let mut new_highlight = true; - if let Some((last_range, last_style)) = highlights.last_mut() - && last_range.end == last_run_len - && last_style == &MarkdownHighlight::Style(style.clone()) - { - last_range.end = text.len(); - new_highlight = false; - } - if new_highlight { - highlights.push(( - last_run_len..text.len(), - MarkdownHighlight::Style(style.clone()), - )); - } - } - } - Event::Code(t) => { - text.push_str(t.as_ref()); - let range = prev_len..text.len(); - - if link.is_some() { - highlights.push(( - range.clone(), - MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - }), - )); - } - regions.push(( - range, - ParsedRegion { - code: true, - link: link.clone(), - }, - )); - } - Event::Start(tag) => match tag { - Tag::Emphasis => italic_depth += 1, - Tag::Strong => bold_depth += 1, - Tag::Strikethrough => strikethrough_depth += 1, - Tag::Link { dest_url, .. } => { - link = Link::identify( - self.file_location_directory.clone(), - dest_url.to_string(), - ); - } - Tag::Image { dest_url, .. } => { - if !text.is_empty() { - let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: source_range.clone(), - contents: mem::take(&mut text).into(), - highlights: mem::take(&mut highlights), - regions: mem::take(&mut regions), - }); - markdown_text_like.push(parsed_regions); - } - image = Image::identify( - dest_url.to_string(), - source_range.clone(), - self.file_location_directory.clone(), - ); - } - _ => { - break; - } - }, - - Event::End(tag) => match tag { - TagEnd::Emphasis => italic_depth -= 1, - TagEnd::Strong => bold_depth -= 1, - TagEnd::Strikethrough => strikethrough_depth -= 1, - TagEnd::Link => { - link = None; - } - TagEnd::Image => { - if let Some(mut image) = image.take() { - if !text.is_empty() { - image.set_alt_text(std::mem::take(&mut text).into()); - mem::take(&mut highlights); - mem::take(&mut regions); - } - markdown_text_like.push(MarkdownParagraphChunk::Image(image)); - } - } - TagEnd::Paragraph => { - self.cursor += 1; - break; - } - _ => { - break; - } - }, - _ => { - break; - } - } - - self.cursor += 1; - } - if !text.is_empty() { - markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - contents: text.into(), - highlights, - regions, - })); - } - markdown_text_like - } - - fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let text = self.parse_text(true, None); - - // Advance past the heading end tag - self.cursor += 1; - - ParsedMarkdownHeading { - source_range, - level: match level { - pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1, - pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2, - pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3, - pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4, - pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5, - pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6, - }, - contents: text, - } - } - - fn parse_table(&mut self, alignment: Vec) -> ParsedMarkdownTable { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let mut header = vec![]; - let mut body = vec![]; - let mut row_columns = vec![]; - let mut in_header = true; - let column_alignments = alignment - .iter() - .map(Self::convert_alignment) - .collect::>(); - - loop { - if self.eof() { - break; - } - - let (current, source_range) = self.current().unwrap(); - let source_range = source_range.clone(); - match current { - Event::Start(Tag::TableHead) - | Event::Start(Tag::TableRow) - | Event::End(TagEnd::TableCell) => { - self.cursor += 1; - } - Event::Start(Tag::TableCell) => { - self.cursor += 1; - let cell_contents = self.parse_text(false, Some(source_range)); - row_columns.push(ParsedMarkdownTableColumn { - col_span: 1, - row_span: 1, - is_header: in_header, - children: cell_contents, - alignment: column_alignments - .get(row_columns.len()) - .copied() - .unwrap_or_default(), - }); - } - Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => { - self.cursor += 1; - let columns = std::mem::take(&mut row_columns); - if in_header { - header.push(ParsedMarkdownTableRow { columns: columns }); - in_header = false; - } else { - body.push(ParsedMarkdownTableRow::with_columns(columns)); - } - } - Event::End(TagEnd::Table) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - ParsedMarkdownTable { - source_range, - header, - body, - caption: None, - } - } - - fn convert_alignment(alignment: &Alignment) -> ParsedMarkdownTableAlignment { - match alignment { - Alignment::None => ParsedMarkdownTableAlignment::None, - Alignment::Left => ParsedMarkdownTableAlignment::Left, - Alignment::Center => ParsedMarkdownTableAlignment::Center, - Alignment::Right => ParsedMarkdownTableAlignment::Right, - } - } - - async fn parse_list(&mut self, order: Option) -> Vec { - let (_, list_source_range) = self.previous().unwrap(); - - let mut items = Vec::new(); - let mut items_stack = vec![MarkdownListItem::default()]; - let mut depth = 1; - let mut order = order; - let mut order_stack = Vec::new(); - - let mut insertion_indices = FxHashMap::default(); - let mut source_ranges = FxHashMap::default(); - let mut start_item_range = list_source_range.clone(); - - while !self.eof() { - let (current, source_range) = self.current().unwrap(); - match current { - Event::Start(Tag::List(new_order)) => { - if items_stack.last().is_some() && !insertion_indices.contains_key(&depth) { - insertion_indices.insert(depth, items.len()); - } - - // We will use the start of the nested list as the end for the current item's range, - // because we don't care about the hierarchy of list items - if let collections::hash_map::Entry::Vacant(e) = source_ranges.entry(depth) { - e.insert(start_item_range.start..source_range.start); - } - - order_stack.push(order); - order = *new_order; - self.cursor += 1; - depth += 1; - } - Event::End(TagEnd::List(_)) => { - order = order_stack.pop().flatten(); - self.cursor += 1; - depth -= 1; - - if depth == 0 { - break; - } - } - Event::Start(Tag::Item) => { - start_item_range = source_range.clone(); - - self.cursor += 1; - items_stack.push(MarkdownListItem::default()); - - let mut task_list = None; - // Check for task list marker (`- [ ]` or `- [x]`) - if let Some(event) = self.current_event() { - // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph - if event == &Event::Start(Tag::Paragraph) { - self.cursor += 1; - } - - if let Some((Event::TaskListMarker(checked), range)) = self.current() { - task_list = Some((*checked, range.clone())); - self.cursor += 1; - } - } - - if let Some((event, range)) = self.current() { - // This is a plain list item. - // For example `- some text` or `1. [Docs](./docs.md)` - if MarkdownParser::is_text_like(event) { - let text = self.parse_text(false, Some(range.clone())); - let block = ParsedMarkdownElement::Paragraph(text); - if let Some(content) = items_stack.last_mut() { - let item_type = if let Some((checked, range)) = task_list { - ParsedMarkdownListItemType::Task(checked, range) - } else if let Some(order) = order { - ParsedMarkdownListItemType::Ordered(order) - } else { - ParsedMarkdownListItemType::Unordered - }; - content.item_type = item_type; - content.content.push(block); - } - } else { - let block = self.parse_block().await; - if let Some(block) = block - && let Some(list_item) = items_stack.last_mut() - { - list_item.content.extend(block); - } - } - } - - // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph - if self.current_event() == Some(&Event::End(TagEnd::Paragraph)) { - self.cursor += 1; - } - } - Event::End(TagEnd::Item) => { - self.cursor += 1; - - if let Some(current) = order { - order = Some(current + 1); - } - - if let Some(list_item) = items_stack.pop() { - let source_range = source_ranges - .remove(&depth) - .unwrap_or(start_item_range.clone()); - - // We need to remove the last character of the source range, because it includes the newline character - let source_range = source_range.start..source_range.end - 1; - let item = ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - content: list_item.content, - depth, - item_type: list_item.item_type, - nested: false, - }); - - if let Some(index) = insertion_indices.get(&depth) { - items.insert(*index, item); - insertion_indices.remove(&depth); - } else { - items.push(item); - } - } - } - _ => { - if depth == 0 { - break; - } - // This can only happen if a list item starts with more then one paragraph, - // or the list item contains blocks that should be rendered after the nested list items - let block = self.parse_block().await; - if let Some(block) = block { - if let Some(list_item) = items_stack.last_mut() { - // 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 - if !insertion_indices.contains_key(&depth) { - list_item.content.extend(block); - continue; - } - } - - // Otherwise we need to insert the block after all the nested items - // that have been parsed so far - items.extend(block); - } else { - self.cursor += 1; - } - } - } - } - - items - } - - #[async_recursion] - async fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let mut nested_depth = 1; - - let mut children: Vec = vec![]; - - while !self.eof() { - let block = self.parse_block().await; - - if let Some(block) = block { - children.extend(block); - } else { - break; - } - - if self.eof() { - break; - } - - let (current, _source_range) = self.current().unwrap(); - match current { - // This is a nested block quote. - // Record that we're in a nested block quote and continue parsing. - // We don't need to advance the cursor since the next - // call to `parse_block` will handle it. - Event::Start(Tag::BlockQuote(_kind)) => { - nested_depth += 1; - } - Event::End(TagEnd::BlockQuote(_kind)) => { - nested_depth -= 1; - if nested_depth == 0 { - self.cursor += 1; - break; - } - } - _ => {} - }; - } - - ParsedMarkdownBlockQuote { - source_range, - children, - } - } - - async fn parse_code_block( - &mut self, - language: Option, - ) -> Option { - let Some((_event, source_range)) = self.previous() else { - return None; - }; - - let source_range = source_range.clone(); - let mut code = String::new(); - - while !self.eof() { - let Some((current, _source_range)) = self.current() else { - break; - }; - - match current { - Event::Text(text) => { - code.push_str(text); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - code = code.strip_suffix('\n').unwrap_or(&code).to_string(); - - let highlights = if let Some(language) = &language { - if let Some(registry) = &self.language_registry { - let rope: language::Rope = code.as_str().into(); - registry - .language_for_name_or_extension(language) - .await - .map(|l| l.highlight_text(&rope, 0..code.len())) - .ok() - } else { - None - } - } else { - None - }; - - Some(ParsedMarkdownCodeBlock { - source_range, - contents: code.into(), - language, - highlights, - }) - } - - async fn parse_mermaid_diagram( - &mut self, - scale: Option, - ) -> Option { - let Some((_event, source_range)) = self.previous() else { - return None; - }; - - let source_range = source_range.clone(); - let mut code = String::new(); - - while !self.eof() { - let Some((current, _source_range)) = self.current() else { - break; - }; - - match current { - Event::Text(text) => { - code.push_str(text); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - code = code.strip_suffix('\n').unwrap_or(&code).to_string(); - - let scale = scale.unwrap_or(100).clamp(10, 500); - - Some(ParsedMarkdownMermaidDiagram { - source_range, - contents: ParsedMarkdownMermaidDiagramContents { - contents: code.into(), - scale, - }, - }) - } - - async fn parse_html_block(&mut self) -> Vec { - let mut elements = Vec::new(); - let Some((_event, _source_range)) = self.previous() else { - return elements; - }; - - let mut html_source_range_start = None; - let mut html_source_range_end = None; - let mut html_buffer = String::new(); - - while !self.eof() { - let Some((current, source_range)) = self.current() else { - break; - }; - let source_range = source_range.clone(); - match current { - Event::Html(html) => { - html_source_range_start.get_or_insert(source_range.start); - html_source_range_end = Some(source_range.end); - html_buffer.push_str(html); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - let bytes = cleanup_html(&html_buffer); - - let mut cursor = std::io::Cursor::new(bytes); - if let Ok(dom) = parse_document(RcDom::default(), ParseOpts::default()) - .from_utf8() - .read_from(&mut cursor) - && let Some((start, end)) = html_source_range_start.zip(html_source_range_end) - { - self.parse_html_node( - start..end, - &dom.document, - &mut elements, - &ParseHtmlNodeContext::default(), - ); - } - - elements - } - - #[stacksafe] - fn parse_html_node( - &self, - source_range: Range, - node: &Rc, - elements: &mut Vec, - context: &ParseHtmlNodeContext, - ) { - match &node.data { - markup5ever_rcdom::NodeData::Document => { - self.consume_children(source_range, node, elements, context); - } - markup5ever_rcdom::NodeData::Text { contents } => { - elements.push(ParsedMarkdownElement::Paragraph(vec![ - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - regions: Vec::default(), - highlights: Vec::default(), - contents: contents.borrow().to_string().into(), - }), - ])); - } - markup5ever_rcdom::NodeData::Comment { .. } => {} - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - let mut styles = if let Some(styles) = Self::markdown_style_from_html_styles( - Self::extract_styles_from_attributes(attrs), - ) { - vec![MarkdownHighlight::Style(styles)] - } else { - Vec::default() - }; - - if local_name!("img") == name.local { - if let Some(image) = self.extract_image(source_range, attrs) { - elements.push(ParsedMarkdownElement::Image(image)); - } - } else if local_name!("p") == name.local { - let mut paragraph = MarkdownParagraph::new(); - self.parse_paragraph( - source_range, - node, - &mut paragraph, - &mut styles, - &mut Vec::new(), - ); - - if !paragraph.is_empty() { - elements.push(ParsedMarkdownElement::Paragraph(paragraph)); - } - } else if matches!( - name.local, - local_name!("h1") - | local_name!("h2") - | local_name!("h3") - | local_name!("h4") - | local_name!("h5") - | local_name!("h6") - ) { - let mut paragraph = MarkdownParagraph::new(); - self.consume_paragraph( - source_range.clone(), - node, - &mut paragraph, - &mut styles, - &mut Vec::new(), - ); - - if !paragraph.is_empty() { - elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: match name.local { - local_name!("h1") => HeadingLevel::H1, - local_name!("h2") => HeadingLevel::H2, - local_name!("h3") => HeadingLevel::H3, - local_name!("h4") => HeadingLevel::H4, - local_name!("h5") => HeadingLevel::H5, - local_name!("h6") => HeadingLevel::H6, - _ => unreachable!(), - }, - contents: paragraph, - })); - } - } else if local_name!("ul") == name.local || local_name!("ol") == name.local { - if let Some(list_items) = self.extract_html_list( - node, - local_name!("ol") == name.local, - context.list_item_depth, - source_range, - ) { - elements.extend(list_items); - } - } else if local_name!("blockquote") == name.local { - if let Some(blockquote) = self.extract_html_blockquote(node, source_range) { - elements.push(ParsedMarkdownElement::BlockQuote(blockquote)); - } - } else if local_name!("table") == name.local { - if let Some(table) = self.extract_html_table(node, source_range) { - elements.push(ParsedMarkdownElement::Table(table)); - } - } else { - self.consume_children(source_range, node, elements, context); - } - } - _ => {} - } - } - - #[stacksafe] - fn parse_paragraph( - &self, - source_range: Range, - node: &Rc, - paragraph: &mut MarkdownParagraph, - highlights: &mut Vec, - regions: &mut Vec<(Range, ParsedRegion)>, - ) { - fn items_with_range( - range: Range, - items: impl IntoIterator, - ) -> Vec<(Range, T)> { - items - .into_iter() - .map(|item| (range.clone(), item)) - .collect() - } - - match &node.data { - markup5ever_rcdom::NodeData::Text { contents } => { - // append the text to the last chunk, so we can have a hacky version - // of inline text with highlighting - if let Some(text) = paragraph.iter_mut().last().and_then(|p| match p { - MarkdownParagraphChunk::Text(text) => Some(text), - _ => None, - }) { - let mut new_text = text.contents.to_string(); - new_text.push_str(&contents.borrow()); - - text.highlights.extend(items_with_range( - text.contents.len()..new_text.len(), - std::mem::take(highlights), - )); - text.regions.extend(items_with_range( - text.contents.len()..new_text.len(), - std::mem::take(regions) - .into_iter() - .map(|(_, region)| region), - )); - text.contents = SharedString::from(new_text); - } else { - let contents = contents.borrow().to_string(); - paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - highlights: items_with_range(0..contents.len(), std::mem::take(highlights)), - regions: items_with_range( - 0..contents.len(), - std::mem::take(regions) - .into_iter() - .map(|(_, region)| region), - ), - contents: contents.into(), - })); - } - } - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - if local_name!("img") == name.local { - if let Some(image) = self.extract_image(source_range, attrs) { - paragraph.push(MarkdownParagraphChunk::Image(image)); - } - } else if local_name!("b") == name.local || local_name!("strong") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("i") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - italic: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("em") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - oblique: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("del") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - strikethrough: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("ins") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("a") == name.local { - if let Some(url) = Self::attr_value(attrs, local_name!("href")) - && let Some(link) = - Link::identify(self.file_location_directory.clone(), url) - { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - })); - - regions.push(( - source_range.clone(), - ParsedRegion { - code: false, - link: Some(link), - }, - )); - } - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else { - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } - } - _ => {} - } - } - - fn consume_paragraph( - &self, - source_range: Range, - node: &Rc, - paragraph: &mut MarkdownParagraph, - highlights: &mut Vec, - regions: &mut Vec<(Range, ParsedRegion)>, - ) { - for node in node.children.borrow().iter() { - self.parse_paragraph(source_range.clone(), node, paragraph, highlights, regions); - } - } - - fn parse_table_row( - &self, - source_range: Range, - node: &Rc, - ) -> Option { - let mut columns = Vec::new(); - - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("tr") != name.local { - return None; - } - - for node in node.children.borrow().iter() { - if let Some(column) = self.parse_table_column(source_range.clone(), node) { - columns.push(column); - } - } - } - _ => {} - } - - if columns.is_empty() { - None - } else { - Some(ParsedMarkdownTableRow { columns }) - } - } - - fn parse_table_column( - &self, - source_range: Range, - node: &Rc, - ) -> Option { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - if !matches!(name.local, local_name!("th") | local_name!("td")) { - return None; - } - - let mut children = MarkdownParagraph::new(); - self.consume_paragraph( - source_range, - node, - &mut children, - &mut Vec::new(), - &mut Vec::new(), - ); - - let is_header = matches!(name.local, local_name!("th")); - - Some(ParsedMarkdownTableColumn { - col_span: std::cmp::max( - Self::attr_value(attrs, local_name!("colspan")) - .and_then(|span| span.parse().ok()) - .unwrap_or(1), - 1, - ), - row_span: std::cmp::max( - Self::attr_value(attrs, local_name!("rowspan")) - .and_then(|span| span.parse().ok()) - .unwrap_or(1), - 1, - ), - is_header, - children, - alignment: Self::attr_value(attrs, local_name!("align")) - .and_then(|align| match align.as_str() { - "left" => Some(ParsedMarkdownTableAlignment::Left), - "center" => Some(ParsedMarkdownTableAlignment::Center), - "right" => Some(ParsedMarkdownTableAlignment::Right), - _ => None, - }) - .unwrap_or_else(|| { - if is_header { - ParsedMarkdownTableAlignment::Center - } else { - ParsedMarkdownTableAlignment::default() - } - }), - }) - } - _ => None, - } - } - - fn consume_children( - &self, - source_range: Range, - node: &Rc, - elements: &mut Vec, - context: &ParseHtmlNodeContext, - ) { - for node in node.children.borrow().iter() { - self.parse_html_node(source_range.clone(), node, elements, context); - } - } - - fn attr_value( - attrs: &RefCell>, - name: html5ever::LocalName, - ) -> Option { - attrs.borrow().iter().find_map(|attr| { - if attr.name.local == name { - Some(attr.value.to_string()) - } else { - None - } - }) - } - - fn markdown_style_from_html_styles( - styles: HashMap, - ) -> Option { - let mut markdown_style = MarkdownHighlightStyle::default(); - - if let Some(text_decoration) = styles.get("text-decoration") { - match text_decoration.to_lowercase().as_str() { - "underline" => { - markdown_style.underline = true; - } - "line-through" => { - markdown_style.strikethrough = true; - } - _ => {} - } - } - - if let Some(font_style) = styles.get("font-style") { - match font_style.to_lowercase().as_str() { - "italic" => { - markdown_style.italic = true; - } - "oblique" => { - markdown_style.oblique = true; - } - _ => {} - } - } - - if let Some(font_weight) = styles.get("font-weight") { - match font_weight.to_lowercase().as_str() { - "bold" => { - markdown_style.weight = FontWeight::BOLD; - } - "lighter" => { - markdown_style.weight = FontWeight::THIN; - } - _ => { - if let Some(weight) = font_weight.parse::().ok() { - markdown_style.weight = FontWeight(weight); - } - } - } - } - - if markdown_style != MarkdownHighlightStyle::default() { - Some(markdown_style) - } else { - None - } - } - - fn extract_styles_from_attributes( - attrs: &RefCell>, - ) -> HashMap { - let mut styles = HashMap::new(); - - if let Some(style) = Self::attr_value(attrs, local_name!("style")) { - for decl in style.split(';') { - let mut parts = decl.splitn(2, ':'); - if let Some((key, value)) = parts.next().zip(parts.next()) { - styles.insert( - key.trim().to_lowercase().to_string(), - value.trim().to_string(), - ); - } - } - } - - styles - } - - fn extract_image( - &self, - source_range: Range, - attrs: &RefCell>, - ) -> Option { - let src = Self::attr_value(attrs, local_name!("src"))?; - - let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?; - - if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) { - image.set_alt_text(alt.into()); - } - - let styles = Self::extract_styles_from_attributes(attrs); - - if let Some(width) = Self::attr_value(attrs, local_name!("width")) - .or_else(|| styles.get("width").cloned()) - .and_then(|width| Self::parse_html_element_dimension(&width)) - { - image.set_width(width); - } - - if let Some(height) = Self::attr_value(attrs, local_name!("height")) - .or_else(|| styles.get("height").cloned()) - .and_then(|height| Self::parse_html_element_dimension(&height)) - { - image.set_height(height); - } - - Some(image) - } - - fn extract_html_list( - &self, - node: &Rc, - ordered: bool, - depth: u16, - source_range: Range, - ) -> Option> { - let mut list_items = Vec::with_capacity(node.children.borrow().len()); - - for (index, node) in node.children.borrow().iter().enumerate() { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("li") != name.local { - continue; - } - - let mut content = Vec::new(); - self.consume_children( - source_range.clone(), - node, - &mut content, - &ParseHtmlNodeContext { - list_item_depth: depth + 1, - }, - ); - - if !content.is_empty() { - list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - depth, - source_range: source_range.clone(), - item_type: if ordered { - ParsedMarkdownListItemType::Ordered(index as u64 + 1) - } else { - ParsedMarkdownListItemType::Unordered - }, - content, - nested: true, - })); - } - } - _ => {} - } - } - - if list_items.is_empty() { - None - } else { - Some(list_items) - } - } - - fn parse_html_element_dimension(value: &str) -> Option { - if value.ends_with("%") { - value - .trim_end_matches("%") - .parse::() - .ok() - .map(|value| relative(value / 100.)) - } else { - value - .trim_end_matches("px") - .parse() - .ok() - .map(|value| px(value).into()) - } - } - - fn extract_html_blockquote( - &self, - node: &Rc, - source_range: Range, - ) -> Option { - let mut children = Vec::new(); - self.consume_children( - source_range.clone(), - node, - &mut children, - &ParseHtmlNodeContext::default(), - ); - - if children.is_empty() { - None - } else { - Some(ParsedMarkdownBlockQuote { - children, - source_range, - }) - } - } - - fn extract_html_table( - &self, - node: &Rc, - source_range: Range, - ) -> Option { - let mut header_rows = Vec::new(); - let mut body_rows = Vec::new(); - let mut caption = None; - - // node should be a thead, tbody or caption element - for node in node.children.borrow().iter() { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("caption") == name.local { - let mut paragraph = MarkdownParagraph::new(); - self.parse_paragraph( - source_range.clone(), - node, - &mut paragraph, - &mut Vec::new(), - &mut Vec::new(), - ); - caption = Some(paragraph); - } - if local_name!("thead") == name.local { - // node should be a tr element - for node in node.children.borrow().iter() { - if let Some(row) = self.parse_table_row(source_range.clone(), node) { - header_rows.push(row); - } - } - } else if local_name!("tbody") == name.local { - // node should be a tr element - for node in node.children.borrow().iter() { - if let Some(row) = self.parse_table_row(source_range.clone(), node) { - body_rows.push(row); - } - } - } - } - _ => {} - } - } - - if !header_rows.is_empty() || !body_rows.is_empty() { - Some(ParsedMarkdownTable { - source_range, - body: body_rows, - header: header_rows, - caption, - }) - } else { - None - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ParsedMarkdownListItemType::*; - use core::panic; - use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength}; - use language::{HighlightId, LanguageRegistry}; - use pretty_assertions::assert_eq; - - async fn parse(input: &str) -> ParsedMarkdown { - parse_markdown(input, None, None).await - } - - #[gpui::test] - async fn test_headings() { - let parsed = parse("# Heading one\n## Heading two\n### Heading three").await; - - assert_eq!( - parsed.children, - vec![ - h1(text("Heading one", 2..13), 0..14), - h2(text("Heading two", 17..28), 14..29), - h3(text("Heading three", 33..46), 29..46), - ] - ); - } - - #[gpui::test] - async fn test_newlines_dont_new_paragraphs() { - let parsed = parse("Some text **that is bolded**\n and *italicized*").await; - - assert_eq!( - parsed.children, - vec![p("Some text that is bolded and italicized", 0..46)] - ); - } - - #[gpui::test] - async fn test_heading_with_paragraph() { - let parsed = parse("# Zed\nThe editor").await; - - assert_eq!( - parsed.children, - vec![h1(text("Zed", 2..5), 0..6), p("The editor", 6..16),] - ); - } - - #[gpui::test] - async fn test_double_newlines_do_new_paragraphs() { - let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await; - - assert_eq!( - parsed.children, - vec![ - p("Some text that is bolded", 0..29), - p("and italicized", 31..47), - ] - ); - } - - #[gpui::test] - async fn test_bold_italic_text() { - let parsed = parse("Some text **that is bolded** and *italicized*").await; - - assert_eq!( - parsed.children, - vec![p("Some text that is bolded and italicized", 0..45)] - ); - } - - #[gpui::test] - async fn test_nested_bold_strikethrough_text() { - let parsed = parse("Some **bo~~strikethrough~~ld** text").await; - - assert_eq!(parsed.children.len(), 1); - assert_eq!( - parsed.children[0], - ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text( - ParsedMarkdownText { - source_range: 0..35, - contents: "Some bostrikethroughld text".into(), - highlights: Vec::new(), - regions: Vec::new(), - } - )]) - ); - - let new_text = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - let paragraph = if let MarkdownParagraphChunk::Text(text) = &new_text[0] { - text - } else { - panic!("Expected a text"); - }; - - assert_eq!( - paragraph.highlights, - vec![ - ( - 5..7, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - }), - ), - ( - 7..20, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - strikethrough: true, - ..Default::default() - }), - ), - ( - 20..22, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - }), - ), - ] - ); - } - - #[gpui::test] - async fn test_html_inline_style_elements() { - let parsed = - parse("

Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text

").await; - - assert_eq!(1, parsed.children.len()); - let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] { - chunks - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(1, chunks.len()); - let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(0..205, text.source_range); - assert_eq!( - "Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text", - text.contents.as_str(), - ); - assert_eq!( - vec![ - ( - 10..21, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(700.0), - ..Default::default() - },), - ), - ( - 32..41, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(700.0), - ..Default::default() - },), - ), - ( - 52..63, - MarkdownHighlight::Style(MarkdownHighlightStyle { - italic: true, - weight: FontWeight(400.0), - ..Default::default() - },), - ), - ( - 74..89, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(400.0), - oblique: true, - ..Default::default() - },), - ), - ( - 100..112, - MarkdownHighlight::Style(MarkdownHighlightStyle { - strikethrough: true, - weight: FontWeight(400.0), - ..Default::default() - },), - ), - ( - 123..136, - MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - weight: FontWeight(400.0,), - ..Default::default() - },), - ), - ], - text.highlights - ); - } - - #[gpui::test] - async fn test_html_href_element() { - let parsed = - parse("

Some text link more text

").await; - - assert_eq!(1, parsed.children.len()); - let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] { - chunks - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(1, chunks.len()); - let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(0..65, text.source_range); - assert_eq!("Some text link more text", text.contents.as_str(),); - assert_eq!( - vec![( - 10..14, - MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - },), - )], - text.highlights - ); - assert_eq!( - vec![( - 10..14, - ParsedRegion { - code: false, - link: Some(Link::Web { - url: "https://example.com".into() - }) - } - )], - text.regions - ) - } - - #[gpui::test] - async fn test_text_with_inline_html() { - let parsed = parse("This is a paragraph with an inline HTML tag.").await; - - assert_eq!( - parsed.children, - vec![p("This is a paragraph with an inline HTML tag.", 0..63),], - ); - } - - #[gpui::test] - async fn test_raw_links_detection() { - let parsed = parse("Checkout this https://zed.dev link").await; - - assert_eq!( - parsed.children, - vec![p("Checkout this https://zed.dev link", 0..34)] - ); - } - - #[gpui::test] - async fn test_empty_image() { - let parsed = parse("![]()").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!(paragraph.len(), 0); - } - - #[gpui::test] - async fn test_image_links_detection() { - let parsed = parse("![test](https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png)").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..111, - link: Link::Web { - url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(), - }, - alt_text: Some("test".into()), - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_alt_text() { - let parsed = parse("[![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev)\n ").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..142, - link: Link::Web { - url: "https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json".to_string(), - }, - alt_text: Some("Zed".into()), - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_without_alt_text() { - let parsed = parse("![](http://example.com/foo.png)").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..31, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_with_alt_text_containing_formatting() { - let parsed = parse("![foo *bar* baz](http://example.com/foo.png)").await; - - let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] else { - panic!("Expected a paragraph"); - }; - assert_eq!( - chunks, - &[MarkdownParagraphChunk::Image(Image { - source_range: 0..44, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("foo bar baz".into()), - height: None, - width: None, - }),], - ); - } - - #[gpui::test] - async fn test_images_with_text_in_between() { - let parsed = parse( - "![foo](http://example.com/foo.png)\nLorem Ipsum\n![bar](http://example.com/bar.png)", - ) - .await; - - let chunks = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - chunks, - &vec![ - MarkdownParagraphChunk::Image(Image { - source_range: 0..81, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("foo".into()), - height: None, - width: None, - }), - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..81, - contents: " Lorem Ipsum ".into(), - highlights: Vec::new(), - regions: Vec::new(), - }), - MarkdownParagraphChunk::Image(Image { - source_range: 0..81, - link: Link::Web { - url: "http://example.com/bar.png".to_string(), - }, - alt_text: Some("bar".into()), - height: None, - width: None, - }) - ] - ); - } - - #[test] - fn test_parse_html_element_dimension() { - // Test percentage values - assert_eq!( - MarkdownParser::parse_html_element_dimension("50%"), - Some(DefiniteLength::Fraction(0.5)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("100%"), - Some(DefiniteLength::Fraction(1.0)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("25%"), - Some(DefiniteLength::Fraction(0.25)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("0%"), - Some(DefiniteLength::Fraction(0.0)) - ); - - // Test pixel values - assert_eq!( - MarkdownParser::parse_html_element_dimension("100px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("50px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("0px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0)))) - ); - - // Test values without units (should be treated as pixels) - assert_eq!( - MarkdownParser::parse_html_element_dimension("100"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("42"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) - ); - - // Test invalid values - assert_eq!( - MarkdownParser::parse_html_element_dimension("invalid"), - None - ); - assert_eq!(MarkdownParser::parse_html_element_dimension("px"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("%"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension(""), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("abc%"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("abcpx"), None); - - // Test decimal values - assert_eq!( - MarkdownParser::parse_html_element_dimension("50.5%"), - Some(DefiniteLength::Fraction(0.505)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("100.25px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("42.0"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) - ); - } - - #[gpui::test] - async fn test_html_unordered_list() { - let parsed = parse( - "
    -
  • Item 1
  • -
  • Item 2
  • -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] - ), - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_ordered_list() { - let parsed = parse( - "
    -
  1. Item 1
  2. -
  3. Item 2
  4. -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] - ), - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Ordered(2), - vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_ordered_list() { - let parsed = parse( - "
    -
  1. Item 1
  2. -
  3. Item 2 -
      -
    1. Sub-Item 1
    2. -
    3. Sub-Item 2
    4. -
    -
  4. -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Ordered(2), - vec![ - ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Ordered(2), - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] - ), - ] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_unordered_list() { - let parsed = parse( - "
    -
  • Item 1
  • -
  • Item 2 -
      -
    • Sub-Item 1
    • -
    • Sub-Item 2
    • -
    -
  • -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ - ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] - ), - ] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_inline_html_image_tag() { - let parsed = - parse("

Some text some more text

") - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Paragraph(vec![ - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..71, - contents: "Some text".into(), - highlights: Default::default(), - regions: Default::default() - }), - MarkdownParagraphChunk::Image(Image { - source_range: 0..71, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - }), - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..71, - contents: " some more text".into(), - highlights: Default::default(), - regions: Default::default() - }), - ])] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_block_quote() { - let parsed = parse( - "
-

some description

-
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![block_quote( - vec![ParsedMarkdownElement::Paragraph(text( - "some description", - 0..78 - ))], - 0..78, - )] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_block_quote() { - let parsed = parse( - "
-

some description

-
-

second description

-
-
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![block_quote( - vec![ - ParsedMarkdownElement::Paragraph(text("some description", 0..179)), - block_quote( - vec![ParsedMarkdownElement::Paragraph(text( - "second description", - 0..179 - ))], - 0..179, - ) - ], - 0..179, - )] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table() { - let parsed = parse( - " - - - - - - - - - - - - - - - - -
IdName
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..366, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Id", 0..366), - ParsedMarkdownTableAlignment::Center - ), - column( - 1, - 1, - true, - text("Name ", 0..366), - ParsedMarkdownTableAlignment::Center - ) - ])], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..366), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..366), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..366), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..366), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_with_caption() { - let parsed = parse( - " - - - - - - - - - - - -
My Table
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..280, - Some(vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..280, - contents: "My Table".into(), - highlights: Default::default(), - regions: Default::default() - })]), - vec![], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..280), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..280), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..280), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..280), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_without_headings() { - let parsed = parse( - " - - - - - - - - - - -
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..240, - None, - vec![], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..240), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..240), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..240), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..240), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_without_body() { - let parsed = parse( - " - - - - - - -
IdName
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..150, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Id", 0..150), - ParsedMarkdownTableAlignment::Center - ), - column( - 1, - 1, - true, - text("Name", 0..150), - ParsedMarkdownTableAlignment::Center - ) - ])], - vec![], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_heading_tags() { - let parsed = parse("

Heading

Heading

Heading

Heading

Heading
Heading
").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H1, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H2, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H3, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H4, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H5, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H6, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag() { - let parsed = parse("").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..40, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag_with_alt_text() { - let parsed = parse("\"Foo\"").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..50, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("Foo".into()), - height: None, - width: None, - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag_with_height_and_width() { - let parsed = - parse("").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..65, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))), - width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))), - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_style_tag_with_height_and_width() { - let parsed = parse( - "", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..75, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))), - width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))), - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_header_only_table() { - let markdown = "\ -| Header 1 | Header 2 | -|----------|----------| - -Some other content -"; - - let expected_table = table( - 0..48, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Header 1", 1..11), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - true, - text("Header 2", 12..22), - ParsedMarkdownTableAlignment::None, - ), - ])], - vec![], - ); - - assert_eq!( - parse(markdown).await.children[0], - ParsedMarkdownElement::Table(expected_table) - ); - } - - #[gpui::test] - async fn test_basic_table() { - let markdown = "\ -| Header 1 | Header 2 | -|----------|----------| -| Cell 1 | Cell 2 | -| Cell 3 | Cell 4 |"; - - let expected_table = table( - 0..95, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Header 1", 1..11), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - true, - text("Header 2", 12..22), - ParsedMarkdownTableAlignment::None, - ), - ])], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("Cell 1", 49..59), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - false, - text("Cell 2", 60..70), - ParsedMarkdownTableAlignment::None, - ), - ]), - row(vec![ - column( - 1, - 1, - false, - text("Cell 3", 73..83), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - false, - text("Cell 4", 84..94), - ParsedMarkdownTableAlignment::None, - ), - ]), - ], - ); - - assert_eq!( - parse(markdown).await.children[0], - ParsedMarkdownElement::Table(expected_table) - ); - } - - #[gpui::test] - async fn test_table_with_checkboxes() { - let markdown = "\ -| Done | Task | -|------|---------| -| [x] | Fix bug | -| [ ] | Add feature |"; - - let parsed = parse(markdown).await; - let table = match &parsed.children[0] { - ParsedMarkdownElement::Table(table) => table, - other => panic!("Expected table, got: {:?}", other), - }; - - let first_cell = &table.body[0].columns[0]; - let first_cell_text = match &first_cell.children[0] { - MarkdownParagraphChunk::Text(t) => t.contents.to_string(), - other => panic!("Expected text chunk, got: {:?}", other), - }; - assert_eq!(first_cell_text.trim(), "[x]"); - - let second_cell = &table.body[1].columns[0]; - let second_cell_text = match &second_cell.children[0] { - MarkdownParagraphChunk::Text(t) => t.contents.to_string(), - other => panic!("Expected text chunk, got: {:?}", other), - }; - assert_eq!(second_cell_text.trim(), "[ ]"); - } - - #[gpui::test] - async fn test_list_basic() { - let parsed = parse( - "\ -* Item 1 -* Item 2 -* Item 3 -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]), - list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]), - list_item(18..26, 1, Unordered, vec![p("Item 3", 20..26)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_tasks() { - let parsed = parse( - "\ -- [ ] TODO -- [x] Checked -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..10, 1, Task(false, 2..5), vec![p("TODO", 6..10)]), - list_item(11..24, 1, Task(true, 13..16), vec![p("Checked", 17..24)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_indented_task() { - let parsed = parse( - "\ -- [ ] TODO - - [x] Checked - - Unordered - 1. Number 1 - 1. Number 2 -1. Number A -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..12, 1, Task(false, 2..5), vec![p("TODO", 6..10)]), - list_item(13..26, 2, Task(true, 15..18), vec![p("Checked", 19..26)]), - list_item(29..40, 2, Unordered, vec![p("Unordered", 31..40)]), - list_item(43..54, 2, Ordered(1), vec![p("Number 1", 46..54)]), - list_item(57..68, 2, Ordered(2), vec![p("Number 2", 60..68)]), - list_item(69..80, 1, Ordered(1), vec![p("Number A", 72..80)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_linebreak_is_handled_correctly() { - let parsed = parse( - "\ -- [ ] Task 1 - -- [x] Task 2 -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..13, 1, Task(false, 2..5), vec![p("Task 1", 6..12)]), - list_item(14..26, 1, Task(true, 16..19), vec![p("Task 2", 20..26)]), - ], - ); - } - - #[gpui::test] - async fn test_list_nested() { - let parsed = parse( - "\ -* Item 1 -* Item 2 -* Item 3 - -1. Hello -1. Two - 1. Three -2. Four -3. Five - -* First - 1. Hello - 1. Goodbyte - - Inner - - Inner - 2. Goodbyte - - Next item empty - - -* Last -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]), - list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]), - list_item(18..27, 1, Unordered, vec![p("Item 3", 20..26)]), - list_item(28..36, 1, Ordered(1), vec![p("Hello", 31..36)]), - list_item(37..46, 1, Ordered(2), vec![p("Two", 40..43),]), - list_item(47..55, 2, Ordered(1), vec![p("Three", 50..55)]), - list_item(56..63, 1, Ordered(3), vec![p("Four", 59..63)]), - list_item(64..72, 1, Ordered(4), vec![p("Five", 67..71)]), - list_item(73..82, 1, Unordered, vec![p("First", 75..80)]), - list_item(83..96, 2, Ordered(1), vec![p("Hello", 86..91)]), - list_item(97..116, 3, Ordered(1), vec![p("Goodbyte", 100..108)]), - list_item(117..124, 4, Unordered, vec![p("Inner", 119..124)]), - list_item(133..140, 4, Unordered, vec![p("Inner", 135..140)]), - list_item(143..159, 2, Ordered(2), vec![p("Goodbyte", 146..154)]), - list_item(160..180, 3, Unordered, vec![p("Next item empty", 165..180)]), - list_item(186..190, 3, Unordered, vec![]), - list_item(191..197, 1, Unordered, vec![p("Last", 193..197)]), - ] - ); - } - - #[gpui::test] - async fn test_list_with_nested_content() { - let parsed = parse( - "\ -* This is a list item with two paragraphs. - - This is the second paragraph in the list item. -", - ) - .await; - - assert_eq!( - parsed.children, - vec![list_item( - 0..96, - 1, - Unordered, - vec![ - p("This is a list item with two paragraphs.", 4..44), - p("This is the second paragraph in the list item.", 50..97) - ], - ),], - ); - } - - #[gpui::test] - async fn test_list_item_with_inline_html() { - let parsed = parse( - "\ -* This is a list item with an inline HTML tag. -", - ) - .await; - - assert_eq!( - parsed.children, - vec![list_item( - 0..67, - 1, - Unordered, - vec![p("This is a list item with an inline HTML tag.", 4..44),], - ),], - ); - } - - #[gpui::test] - async fn test_nested_list_with_paragraph_inside() { - let parsed = parse( - "\ -1. a - 1. b - 1. c - - text - - 1. d -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..7, 1, Ordered(1), vec![p("a", 3..4)],), - list_item(8..20, 2, Ordered(1), vec![p("b", 12..13),],), - list_item(21..27, 3, Ordered(1), vec![p("c", 25..26),],), - p("text", 32..37), - list_item(41..46, 2, Ordered(1), vec![p("d", 45..46),],), - ], - ); - } - - #[gpui::test] - async fn test_list_with_leading_text() { - let parsed = parse( - "\ -* `code` -* **bold** -* [link](https://example.com) -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("code", 2..8)]), - list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]), - list_item(20..49, 1, Unordered, vec![p("link", 22..49)],), - ], - ); - } - - #[gpui::test] - async fn test_simple_block_quote() { - let parsed = parse("> Simple block quote with **styled text**").await; - - assert_eq!( - parsed.children, - vec![block_quote( - vec![p("Simple block quote with styled text", 2..41)], - 0..41 - )] - ); - } - - #[gpui::test] - async fn test_simple_block_quote_with_multiple_lines() { - let parsed = parse( - "\ -> # Heading -> More -> text -> -> More text -", - ) - .await; - - assert_eq!( - parsed.children, - vec![block_quote( - vec![ - h1(text("Heading", 4..11), 2..12), - p("More text", 14..26), - p("More text", 30..40) - ], - 0..40 - )] - ); - } - - #[gpui::test] - async fn test_nested_block_quote() { - let parsed = parse( - "\ -> A -> -> > # B -> -> C - -More text -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - block_quote( - vec![ - p("A", 2..4), - block_quote(vec![h1(text("B", 12..13), 10..14)], 8..14), - p("C", 18..20) - ], - 0..20 - ), - p("More text", 21..31) - ] - ); - } - - #[gpui::test] - async fn test_dollar_signs_are_plain_text() { - // Dollar signs should be preserved as plain text, not treated as math delimiters. - // Regression test for https://github.com/zed-industries/zed/issues/50170 - let parsed = parse("$100$ per unit").await; - assert_eq!(parsed.children, vec![p("$100$ per unit", 0..14)]); - } - - #[gpui::test] - async fn test_dollar_signs_in_list_items() { - let parsed = parse("- $18,000 budget\n- $20,000 budget\n").await; - assert_eq!( - parsed.children, - vec![ - list_item(0..16, 1, Unordered, vec![p("$18,000 budget", 2..16)]), - list_item(17..33, 1, Unordered, vec![p("$20,000 budget", 19..33)]), - ] - ); - } - - #[gpui::test] - async fn test_code_block() { - let parsed = parse( - "\ -``` -fn main() { - return 0; -} -``` -", - ) - .await; - - assert_eq!( - parsed.children, - vec![code_block( - None, - "fn main() {\n return 0;\n}", - 0..35, - None - )] - ); - } - - #[gpui::test] - async fn test_code_block_with_language(executor: BackgroundExecutor) { - let language_registry = Arc::new(LanguageRegistry::test(executor.clone())); - language_registry.add(language::rust_lang()); - - let parsed = parse_markdown( - "\ -```rust -fn main() { - return 0; -} -``` -", - None, - Some(language_registry), - ) - .await; - - assert_eq!( - parsed.children, - vec![code_block( - Some("rust".to_string()), - "fn main() {\n return 0;\n}", - 0..39, - Some(vec![]) - )] - ); - } - - fn h1(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H1, - contents, - }) - } - - fn h2(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H2, - contents, - }) - } - - fn h3(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H3, - contents, - }) - } - - fn p(contents: &str, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Paragraph(text(contents, source_range)) - } - - fn text(contents: &str, source_range: Range) -> MarkdownParagraph { - vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - highlights: Vec::new(), - regions: Vec::new(), - source_range, - contents: contents.to_string().into(), - })] - } - - fn block_quote( - children: Vec, - source_range: Range, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote { - source_range, - children, - }) - } - - fn code_block( - language: Option, - code: &str, - source_range: Range, - highlights: Option, HighlightId)>>, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock { - source_range, - language, - contents: code.to_string().into(), - highlights, - }) - } - - fn list_item( - source_range: Range, - depth: u16, - item_type: ParsedMarkdownListItemType, - content: Vec, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - item_type, - depth, - content, - nested: false, - }) - } - - fn nested_list_item( - source_range: Range, - depth: u16, - item_type: ParsedMarkdownListItemType, - content: Vec, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - item_type, - depth, - content, - nested: true, - }) - } - - fn table( - source_range: Range, - caption: Option, - header: Vec, - body: Vec, - ) -> ParsedMarkdownTable { - ParsedMarkdownTable { - source_range, - header, - body, - caption, - } - } - - fn row(columns: Vec) -> ParsedMarkdownTableRow { - ParsedMarkdownTableRow { columns } - } - - fn column( - col_span: usize, - row_span: usize, - is_header: bool, - children: MarkdownParagraph, - alignment: ParsedMarkdownTableAlignment, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header, - children, - alignment, - } - } - - impl PartialEq for ParsedMarkdownTable { - fn eq(&self, other: &Self) -> bool { - self.source_range == other.source_range - && self.header == other.header - && self.body == other.body - } - } - - impl PartialEq for ParsedMarkdownText { - fn eq(&self, other: &Self) -> bool { - self.source_range == other.source_range && self.contents == other.contents - } - } -} diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index 0a657d27bc1416995d3c4df7f6793c017356fa0d..982eff7c74513cb29b368d49ecd454162f2c3913 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -1,11 +1,7 @@ use gpui::{App, actions}; use workspace::Workspace; -pub mod markdown_elements; -mod markdown_minifier; -pub mod markdown_parser; pub mod markdown_preview_view; -pub mod markdown_renderer; pub use zed_actions::preview::markdown::{OpenPreview, OpenPreviewToTheSide}; diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index b5213504e72a8e99c6405df85001fa615257dc0e..93ae57520d28e38d6ac843d33ab01581d3b8e890 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,46 +1,45 @@ use std::cmp::min; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use std::{ops::Range, path::PathBuf}; use anyhow::Result; use editor::scroll::Autoscroll; use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects}; use gpui::{ - App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, IsZero, ListOffset, ListState, ParentElement, Render, RetainAllImageCache, Styled, - Subscription, Task, WeakEntity, Window, list, + App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement, + IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString, + SharedUri, Subscription, Task, WeakEntity, Window, point, }; use language::LanguageRegistry; +use markdown::{ + CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, +}; use settings::Settings; use theme::ThemeSettings; use ui::{WithScrollbar, prelude::*}; +use util::normalize_path; use workspace::item::{Item, ItemHandle}; -use workspace::{Pane, Workspace}; +use workspace::{OpenOptions, OpenVisible, Pane, Workspace}; -use crate::markdown_elements::ParsedMarkdownElement; -use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState}; use crate::{ - OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp, - markdown_elements::ParsedMarkdown, - markdown_parser::parse_markdown, - markdown_renderer::{RenderContext, render_markdown_block}, + OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem, }; -use crate::{ScrollDown, ScrollDownByItem, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem}; +use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem}; const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); pub struct MarkdownPreviewView { workspace: WeakEntity, - image_cache: Entity, active_editor: Option, focus_handle: FocusHandle, - contents: Option, - selected_block: usize, - list_state: ListState, - language_registry: Arc, - mermaid_state: MermaidState, - parsing_markdown_task: Option>>, + markdown: Entity, + _markdown_subscription: Subscription, + active_source_index: Option, + scroll_handle: ScrollHandle, + image_cache: Entity, + base_directory: Option, + pending_update_task: Option>>, mode: MarkdownPreviewMode, } @@ -205,19 +204,35 @@ impl MarkdownPreviewView { cx: &mut Context, ) -> Entity { cx.new(|cx| { - let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); - + let markdown = cx.new(|cx| { + Markdown::new_with_options( + SharedString::default(), + Some(language_registry), + None, + MarkdownOptions { + parse_html: true, + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ) + }); let mut this = Self { - selected_block: 0, active_editor: None, focus_handle: cx.focus_handle(), workspace: workspace.clone(), - contents: None, - list_state, - language_registry, - mermaid_state: Default::default(), - parsing_markdown_task: None, + _markdown_subscription: cx.observe( + &markdown, + |this: &mut Self, _: Entity, cx| { + this.sync_active_root_block(cx); + }, + ), + markdown, + active_source_index: None, + scroll_handle: ScrollHandle::new(), image_cache: RetainAllImageCache::new(cx), + base_directory: None, + pending_update_task: None, mode, }; @@ -280,17 +295,16 @@ impl MarkdownPreviewView { | EditorEvent::BufferEdited { .. } | EditorEvent::DirtyChanged | EditorEvent::ExcerptsEdited { .. } => { - this.parse_markdown_from_active_editor(true, window, cx); + this.update_markdown_from_active_editor(true, false, window, cx); } EditorEvent::SelectionsChanged { .. } => { - let selection_range = editor.update(cx, |editor, cx| { - editor - .selections - .last::(&editor.display_snapshot(cx)) - .range() - }); - this.selected_block = this.get_block_index_under_cursor(selection_range); - this.list_state.scroll_to_reveal_item(this.selected_block); + let (selection_start, editor_is_focused) = + editor.update(cx, |editor, cx| { + let index = Self::selected_source_index(editor, cx); + let focused = editor.focus_handle(cx).is_focused(window); + (index, focused) + }); + this.sync_preview_to_source_index(selection_start, editor_is_focused, cx); cx.notify(); } _ => {} @@ -298,27 +312,30 @@ impl MarkdownPreviewView { }, ); + self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx); self.active_editor = Some(EditorState { editor, _subscription: subscription, }); - self.parse_markdown_from_active_editor(false, window, cx); + self.update_markdown_from_active_editor(false, true, window, cx); } - fn parse_markdown_from_active_editor( + fn update_markdown_from_active_editor( &mut self, wait_for_debounce: bool, + should_reveal: bool, window: &mut Window, cx: &mut Context, ) { if let Some(state) = &self.active_editor { // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing - if wait_for_debounce && self.parsing_markdown_task.is_some() { + if wait_for_debounce && self.pending_update_task.is_some() { return; } - self.parsing_markdown_task = Some(self.parse_markdown_in_background( + self.pending_update_task = Some(self.schedule_markdown_update( wait_for_debounce, + should_reveal, state.editor.clone(), window, cx, @@ -326,63 +343,97 @@ impl MarkdownPreviewView { } } - fn parse_markdown_in_background( + fn schedule_markdown_update( &mut self, wait_for_debounce: bool, + should_reveal_selection: bool, editor: Entity, window: &mut Window, cx: &mut Context, ) -> Task> { - let language_registry = self.language_registry.clone(); - cx.spawn_in(window, async move |view, cx| { if wait_for_debounce { // Wait for the user to stop typing cx.background_executor().timer(REPARSE_DEBOUNCE).await; } - let (contents, file_location) = view.update(cx, |_, cx| { - let editor = editor.read(cx); - let contents = editor.buffer().read(cx).snapshot(cx).text(); - let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx); - (contents, file_location) - })?; + let editor_clone = editor.clone(); + let update = view.update(cx, |view, cx| { + let is_active_editor = view + .active_editor + .as_ref() + .is_some_and(|active_editor| active_editor.editor == editor_clone); + if !is_active_editor { + return None; + } - let parsing_task = cx.background_spawn(async move { - parse_markdown(&contents, file_location, Some(language_registry)).await - }); - let contents = parsing_task.await; + let (contents, selection_start) = editor_clone.update(cx, |editor, cx| { + let contents = editor.buffer().read(cx).snapshot(cx).text(); + let selection_start = Self::selected_source_index(editor, cx); + (contents, selection_start) + }); + Some((SharedString::from(contents), selection_start)) + })?; view.update(cx, move |view, cx| { - view.mermaid_state.update(&contents, cx); - let markdown_blocks_count = contents.children.len(); - view.contents = Some(contents); - let scroll_top = view.list_state.logical_scroll_top(); - view.list_state.reset(markdown_blocks_count); - view.list_state.scroll_to(scroll_top); - view.parsing_markdown_task = None; + if let Some((contents, selection_start)) = update { + view.markdown.update(cx, |markdown, cx| { + markdown.reset(contents, cx); + }); + view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx); + } + view.pending_update_task = None; cx.notify(); }) }) } - fn move_cursor_to_block( - &self, - window: &mut Window, + fn selected_source_index(editor: &Editor, cx: &mut App) -> usize { + editor + .selections + .last::(&editor.display_snapshot(cx)) + .range() + .start + .0 + } + + fn sync_preview_to_source_index( + &mut self, + source_index: usize, + reveal: bool, cx: &mut Context, - selection: Range, ) { - if let Some(state) = &self.active_editor { - state.editor.update(cx, |editor, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |selections| selections.select_ranges(vec![selection]), - ); - window.focus(&editor.focus_handle(cx), cx); - }); - } + self.active_source_index = Some(source_index); + self.sync_active_root_block(cx); + self.markdown.update(cx, |markdown, cx| { + if reveal { + markdown.request_autoscroll_to_source_index(source_index, cx); + } + }); + } + + fn sync_active_root_block(&mut self, cx: &mut Context) { + self.markdown.update(cx, |markdown, cx| { + markdown.set_active_root_for_source_index(self.active_source_index, cx); + }); + } + + fn move_cursor_to_source_index( + editor: &Entity, + source_index: usize, + window: &mut Window, + cx: &mut App, + ) { + editor.update(cx, |editor, cx| { + let selection = MultiBufferOffset(source_index)..MultiBufferOffset(source_index); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| selections.select_ranges(vec![selection]), + ); + window.focus(&editor.focus_handle(cx), cx); + }); } /// The absolute path of the file that is currently being previewed. @@ -398,52 +449,24 @@ impl MarkdownPreviewView { } } - fn get_block_index_under_cursor(&self, selection_range: Range) -> usize { - let mut block_index = None; - let cursor = selection_range.start.0; - - let mut last_end = 0; - if let Some(content) = &self.contents { - for (i, block) in content.children.iter().enumerate() { - let Some(Range { start, end }) = block.source_range() else { - continue; - }; - - // Check if the cursor is between the last block and the current block - if last_end <= cursor && cursor < start { - block_index = Some(i.saturating_sub(1)); - break; - } - - if start <= cursor && end >= cursor { - block_index = Some(i); - break; - } - last_end = end; - } - - if block_index.is_none() && last_end < cursor { - block_index = Some(content.children.len().saturating_sub(1)); - } - } - - block_index.unwrap_or_default() + fn line_scroll_amount(&self, cx: &App) -> Pixels { + let settings = ThemeSettings::get_global(cx); + settings.buffer_font_size(cx) * settings.buffer_line_height.value() } - fn should_apply_padding_between( - current_block: &ParsedMarkdownElement, - next_block: Option<&ParsedMarkdownElement>, - ) -> bool { - !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false)) + fn scroll_by_amount(&self, distance: Pixels) { + let offset = self.scroll_handle.offset(); + self.scroll_handle + .set_offset(point(offset.x, offset.y - distance)); } fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context) { - let viewport_height = self.list_state.viewport_bounds().size.height; + let viewport_height = self.scroll_handle.bounds().size.height; if viewport_height.is_zero() { return; } - self.list_state.scroll_by(-viewport_height); + self.scroll_by_amount(-viewport_height); cx.notify(); } @@ -453,35 +476,49 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let viewport_height = self.list_state.viewport_bounds().size.height; + let viewport_height = self.scroll_handle.bounds().size.height; if viewport_height.is_zero() { return; } - self.list_state.scroll_by(viewport_height); + self.scroll_by_amount(viewport_height); cx.notify(); } fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context) { - let scroll_top = self.list_state.logical_scroll_top(); - if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { let item_height = bounds.size.height; // Scroll no more than the rough equivalent of a large headline let max_height = window.rem_size() * 2; let scroll_height = min(item_height, max_height); - self.list_state.scroll_by(-scroll_height); + self.scroll_by_amount(-scroll_height); + } else { + let scroll_height = self.line_scroll_amount(cx); + if !scroll_height.is_zero() { + self.scroll_by_amount(-scroll_height); + } } cx.notify(); } fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context) { - let scroll_top = self.list_state.logical_scroll_top(); - if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { let item_height = bounds.size.height; // Scroll no more than the rough equivalent of a large headline let max_height = window.rem_size() * 2; let scroll_height = min(item_height, max_height); - self.list_state.scroll_by(scroll_height); + self.scroll_by_amount(scroll_height); + } else { + let scroll_height = self.line_scroll_amount(cx); + if !scroll_height.is_zero() { + self.scroll_by_amount(scroll_height); + } } cx.notify(); } @@ -492,9 +529,11 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let scroll_top = self.list_state.logical_scroll_top(); - if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { - self.list_state.scroll_by(-bounds.size.height); + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { + self.scroll_by_amount(-bounds.size.height); } cx.notify(); } @@ -505,18 +544,17 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let scroll_top = self.list_state.logical_scroll_top(); - if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { - self.list_state.scroll_by(bounds.size.height); + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { + self.scroll_by_amount(bounds.size.height); } cx.notify(); } fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context) { - self.list_state.scroll_to(ListOffset { - item_ix: 0, - offset_in_item: px(0.), - }); + self.scroll_handle.scroll_to_item(0); cx.notify(); } @@ -526,19 +564,157 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let count = self.list_state.item_count(); - if count > 0 { - self.list_state.scroll_to(ListOffset { - item_ix: count - 1, - offset_in_item: px(0.), - }); - } + self.scroll_handle.scroll_to_bottom(); cx.notify(); } + + fn render_markdown_element( + &self, + window: &mut Window, + cx: &mut Context, + ) -> MarkdownElement { + let workspace = self.workspace.clone(); + let base_directory = self.base_directory.clone(); + let active_editor = self + .active_editor + .as_ref() + .map(|state| state.editor.clone()); + + let mut markdown_element = MarkdownElement::new( + self.markdown.clone(), + MarkdownStyle::themed(MarkdownFont::Editor, window, cx), + ) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: true, + border: false, + }) + .scroll_handle(self.scroll_handle.clone()) + .show_root_block_markers() + .image_resolver({ + let base_directory = self.base_directory.clone(); + move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref()) + }) + .on_url_click(move |url, window, cx| { + open_preview_url(url, base_directory.clone(), &workspace, window, cx); + }); + + if let Some(active_editor) = active_editor { + let editor_for_checkbox = active_editor.clone(); + let view_handle = cx.entity().downgrade(); + markdown_element = markdown_element + .on_source_click(move |source_index, click_count, window, cx| { + if click_count == 2 { + Self::move_cursor_to_source_index(&active_editor, source_index, window, cx); + true + } else { + false + } + }) + .on_checkbox_toggle(move |source_range, new_checked, window, cx| { + let task_marker = if new_checked { "[x]" } else { "[ ]" }; + editor_for_checkbox.update(cx, |editor, cx| { + editor.edit( + [( + MultiBufferOffset(source_range.start) + ..MultiBufferOffset(source_range.end), + task_marker, + )], + cx, + ); + }); + if let Some(view) = view_handle.upgrade() { + cx.update_entity(&view, |this, cx| { + this.update_markdown_from_active_editor(false, false, window, cx); + }); + } + }); + } + + markdown_element + } +} + +fn open_preview_url( + url: SharedString, + base_directory: Option, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, +) { + if let Some(path) = resolve_preview_path(url.as_ref(), base_directory.as_deref()) + && let Some(workspace) = workspace.upgrade() + { + let _ = workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path( + normalize_path(path.as_path()), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) + .detach(); + }); + return; + } + + cx.open_url(url.as_ref()); +} + +fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option { + if url.starts_with("http://") || url.starts_with("https://") { + return None; + } + + let decoded_url = urlencoding::decode(url) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| url.to_string()); + let candidate = PathBuf::from(&decoded_url); + + if candidate.is_absolute() && candidate.exists() { + return Some(candidate); + } + + let base_directory = base_directory?; + let resolved = base_directory.join(decoded_url); + if resolved.exists() { + Some(resolved) + } else { + None + } +} + +fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Option { + if dest_url.starts_with("data:") { + return None; + } + + if dest_url.starts_with("http://") || dest_url.starts_with("https://") { + return Some(ImageSource::Resource(Resource::Uri(SharedUri::from( + dest_url.to_string(), + )))); + } + + let decoded = urlencoding::decode(dest_url) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| dest_url.to_string()); + + let path = if Path::new(&decoded).is_absolute() { + PathBuf::from(decoded) + } else { + base_directory?.join(decoded) + }; + + Some(ImageSource::Resource(Resource::Path(Arc::from( + path.as_path(), + )))) } impl Focusable for MarkdownPreviewView { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } @@ -572,10 +748,7 @@ impl Item for MarkdownPreviewView { impl Render for MarkdownPreviewView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx); - let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height; - - v_flex() + div() .image_cache(self.image_cache.clone()) .id("MarkdownPreview") .key_context("MarkdownPreview") @@ -590,113 +763,65 @@ impl Render for MarkdownPreviewView { .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom)) .size_full() .bg(cx.theme().colors().editor_background) - .p_4() - .text_size(buffer_size) - .line_height(relative(buffer_line_height.value())) - .child(div().flex_grow().map(|this| { - this.child( - list( - self.list_state.clone(), - cx.processor(|this, ix, window, cx| { - let Some(contents) = &this.contents else { - return div().into_any(); - }; - - let mut render_cx = RenderContext::new( - Some(this.workspace.clone()), - &this.mermaid_state, - window, - cx, - ) - .with_checkbox_clicked_callback(cx.listener( - move |this, e: &CheckboxClickedEvent, window, cx| { - if let Some(editor) = - this.active_editor.as_ref().map(|s| s.editor.clone()) - { - editor.update(cx, |editor, cx| { - let task_marker = - if e.checked() { "[x]" } else { "[ ]" }; - - editor.edit( - [( - MultiBufferOffset(e.source_range().start) - ..MultiBufferOffset(e.source_range().end), - task_marker, - )], - cx, - ); - }); - this.parse_markdown_from_active_editor(false, window, cx); - cx.notify(); - } - }, - )); - - let block = contents.children.get(ix).unwrap(); - let rendered_block = render_markdown_block(block, &mut render_cx); - - let should_apply_padding = Self::should_apply_padding_between( - block, - contents.children.get(ix + 1), - ); - - let selected_block = this.selected_block; - let scaled_rems = render_cx.scaled_rems(1.0); - div() - .id(ix) - .when(should_apply_padding, |this| { - this.pb(render_cx.scaled_rems(0.75)) - }) - .group("markdown-block") - .on_click(cx.listener( - move |this, event: &ClickEvent, window, cx| { - if event.click_count() == 2 - && let Some(source_range) = this - .contents - .as_ref() - .and_then(|c| c.children.get(ix)) - .and_then(|block: &ParsedMarkdownElement| { - block.source_range() - }) - { - this.move_cursor_to_block( - window, - cx, - MultiBufferOffset(source_range.start) - ..MultiBufferOffset(source_range.start), - ); - } - }, - )) - .map(move |container| { - let indicator = div() - .h_full() - .w(px(4.0)) - .when(ix == selected_block, |this| { - this.bg(cx.theme().colors().border) - }) - .group_hover("markdown-block", |s| { - if ix == selected_block { - s - } else { - s.bg(cx.theme().colors().border_variant) - } - }) - .rounded_xs(); - - container.child( - div() - .relative() - .child(div().pl(scaled_rems).child(rendered_block)) - .child(indicator.absolute().left_0().top_0()), - ) - }) - .into_any() - }), - ) - .size_full(), - ) - })) - .vertical_scrollbar_for(&self.list_state, window, cx) + .child( + div() + .id("markdown-preview-scroll-container") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .p_4() + .child(self.render_markdown_element(window, cx)), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use std::fs; + use tempfile::TempDir; + + use super::resolve_preview_path; + + #[test] + fn resolves_relative_preview_paths() -> Result<()> { + let temp_dir = TempDir::new()?; + let base_directory = temp_dir.path(); + let file = base_directory.join("notes.md"); + fs::write(&file, "# Notes")?; + + assert_eq!( + resolve_preview_path("notes.md", Some(base_directory)), + Some(file) + ); + assert_eq!( + resolve_preview_path("nonexistent.md", Some(base_directory)), + None + ); + assert_eq!(resolve_preview_path("notes.md", None), None); + + Ok(()) + } + + #[test] + fn resolves_urlencoded_preview_paths() -> Result<()> { + let temp_dir = TempDir::new()?; + let base_directory = temp_dir.path(); + let file = base_directory.join("release notes.md"); + fs::write(&file, "# Release Notes")?; + + assert_eq!( + resolve_preview_path("release%20notes.md", Some(base_directory)), + Some(file) + ); + + Ok(()) + } + + #[test] + fn does_not_treat_web_links_as_preview_paths() { + assert_eq!(resolve_preview_path("https://zed.dev", None), None); + assert_eq!(resolve_preview_path("http://example.com", None), None); } } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs deleted file mode 100644 index 6f7a28db775868e185fe183a1e35d1f7b8eaa662..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ /dev/null @@ -1,1517 +0,0 @@ -use crate::{ - markdown_elements::{ - HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, - ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, - ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, - ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable, - ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, - }, - markdown_preview_view::MarkdownPreviewView, -}; -use collections::HashMap; -use gpui::{ - AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div, - Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, - Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled, - StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems, -}; - -use settings::Settings; -use std::{ - ops::{Mul, Range}, - sync::{Arc, OnceLock}, - time::Duration, - vec, -}; -use theme::{ActiveTheme, SyntaxTheme, ThemeSettings}; -use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container}; -use util::normalize_path; -use workspace::{OpenOptions, OpenVisible, Workspace}; - -pub struct CheckboxClickedEvent { - pub checked: bool, - pub source_range: Range, -} - -impl CheckboxClickedEvent { - pub fn source_range(&self) -> Range { - self.source_range.clone() - } - - pub fn checked(&self) -> bool { - self.checked - } -} - -type CheckboxClickedCallback = Arc>; - -type MermaidDiagramCache = HashMap; - -#[derive(Default)] -pub(crate) struct MermaidState { - cache: MermaidDiagramCache, - order: Vec, -} - -impl MermaidState { - fn get_fallback_image( - idx: usize, - old_order: &[ParsedMarkdownMermaidDiagramContents], - new_order_len: usize, - cache: &MermaidDiagramCache, - ) -> Option> { - // When the diagram count changes e.g. addition or removal, positional matching - // is unreliable since a new diagram at index i likely doesn't correspond to the - // old diagram at index i. We only allow fallbacks when counts match, which covers - // the common case of editing a diagram in-place. - // - // Swapping two diagrams would briefly show the stale fallback, but that's an edge - // case we don't handle. - if old_order.len() != new_order_len { - return None; - } - old_order.get(idx).and_then(|old_content| { - cache.get(old_content).and_then(|old_cached| { - old_cached - .render_image - .get() - .and_then(|result| result.as_ref().ok().cloned()) - // Chain fallbacks for rapid edits. - .or_else(|| old_cached.fallback_image.clone()) - }) - }) - } - - pub(crate) fn update( - &mut self, - parsed: &ParsedMarkdown, - cx: &mut Context, - ) { - use crate::markdown_elements::ParsedMarkdownElement; - use std::collections::HashSet; - - let mut new_order = Vec::new(); - for element in parsed.children.iter() { - if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element { - new_order.push(mermaid_diagram.contents.clone()); - } - } - - for (idx, new_content) in new_order.iter().enumerate() { - if !self.cache.contains_key(new_content) { - let fallback = - Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache); - self.cache.insert( - new_content.clone(), - CachedMermaidDiagram::new(new_content.clone(), fallback, cx), - ); - } - } - - let new_order_set: HashSet<_> = new_order.iter().cloned().collect(); - self.cache - .retain(|content, _| new_order_set.contains(content)); - self.order = new_order; - } -} - -pub(crate) struct CachedMermaidDiagram { - pub(crate) render_image: Arc>>>, - pub(crate) fallback_image: Option>, - _task: Task<()>, -} - -impl CachedMermaidDiagram { - pub(crate) fn new( - contents: ParsedMarkdownMermaidDiagramContents, - fallback_image: Option>, - cx: &mut Context, - ) -> Self { - let result = Arc::new(OnceLock::>>::new()); - let result_clone = result.clone(); - let svg_renderer = cx.svg_renderer(); - - let _task = cx.spawn(async move |this, cx| { - let value = cx - .background_spawn(async move { - let svg_string = mermaid_rs_renderer::render(&contents.contents)?; - let scale = contents.scale as f32 / 100.0; - svg_renderer - .render_single_frame(svg_string.as_bytes(), scale, true) - .map_err(|e| anyhow::anyhow!("{}", e)) - }) - .await; - let _ = result_clone.set(value); - this.update(cx, |_, cx| { - cx.notify(); - }) - .ok(); - }); - - Self { - render_image: result, - fallback_image, - _task, - } - } - - #[cfg(test)] - fn new_for_test( - render_image: Option>, - fallback_image: Option>, - ) -> Self { - let result = Arc::new(OnceLock::new()); - if let Some(img) = render_image { - let _ = result.set(Ok(img)); - } - Self { - render_image: result, - fallback_image, - _task: Task::ready(()), - } - } -} -#[derive(Clone)] -pub struct RenderContext<'a> { - workspace: Option>, - next_id: usize, - buffer_font_family: SharedString, - buffer_text_style: TextStyle, - text_style: TextStyle, - border_color: Hsla, - title_bar_background_color: Hsla, - panel_background_color: Hsla, - text_color: Hsla, - link_color: Hsla, - window_rem_size: Pixels, - text_muted_color: Hsla, - code_block_background_color: Hsla, - code_span_background_color: Hsla, - syntax_theme: Arc, - indent: usize, - checkbox_clicked_callback: Option, - is_last_child: bool, - mermaid_state: &'a MermaidState, -} - -impl<'a> RenderContext<'a> { - pub(crate) fn new( - workspace: Option>, - mermaid_state: &'a MermaidState, - window: &mut Window, - cx: &mut App, - ) -> Self { - let theme = cx.theme().clone(); - - let settings = ThemeSettings::get_global(cx); - let buffer_font_family = settings.buffer_font.family.clone(); - let buffer_font_features = settings.buffer_font.features.clone(); - let mut buffer_text_style = window.text_style(); - buffer_text_style.font_family = buffer_font_family.clone(); - buffer_text_style.font_features = buffer_font_features; - buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx)); - - RenderContext { - workspace, - next_id: 0, - indent: 0, - buffer_font_family, - buffer_text_style, - text_style: window.text_style(), - syntax_theme: theme.syntax().clone(), - border_color: theme.colors().border, - title_bar_background_color: theme.colors().title_bar_background, - panel_background_color: theme.colors().panel_background, - text_color: theme.colors().text, - link_color: theme.colors().text_accent, - window_rem_size: window.rem_size(), - text_muted_color: theme.colors().text_muted, - code_block_background_color: theme.colors().surface_background, - code_span_background_color: theme.colors().editor_document_highlight_read_background, - checkbox_clicked_callback: None, - is_last_child: false, - mermaid_state, - } - } - - pub fn with_checkbox_clicked_callback( - mut self, - callback: impl Fn(&CheckboxClickedEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback))); - self - } - - fn next_id(&mut self, span: &Range) -> ElementId { - let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end); - self.next_id += 1; - ElementId::from(SharedString::from(id)) - } - - /// HACK: used to have rems relative to buffer font size, so that things scale appropriately as - /// buffer font size changes. The callees of this function should be reimplemented to use real - /// relative sizing once that is implemented in GPUI - pub fn scaled_rems(&self, rems: f32) -> Rems { - self.buffer_text_style - .font_size - .to_rems(self.window_rem_size) - .mul(rems) - } - - /// This ensures that children inside of block quotes - /// have padding between them. - /// - /// For example, for this markdown: - /// - /// ```markdown - /// > This is a block quote. - /// > - /// > And this is the next paragraph. - /// ``` - /// - /// We give padding between "This is a block quote." - /// and "And this is the next paragraph." - fn with_common_p(&self, element: Div) -> Div { - if self.indent > 0 && !self.is_last_child { - element.pb(self.scaled_rems(0.75)) - } else { - element - } - } - - /// The is used to indicate that the current element is the last child or not of its parent. - /// - /// Then we can avoid adding padding to the bottom of the last child. - fn with_last_child(&mut self, is_last: bool, render: R) -> AnyElement - where - R: FnOnce(&mut Self) -> AnyElement, - { - self.is_last_child = is_last; - let element = render(self); - self.is_last_child = false; - element - } -} - -pub fn render_parsed_markdown( - parsed: &ParsedMarkdown, - workspace: Option>, - window: &mut Window, - cx: &mut App, -) -> Div { - let cache = Default::default(); - let mut cx = RenderContext::new(workspace, &cache, window, cx); - - v_flex().gap_3().children( - parsed - .children - .iter() - .map(|block| render_markdown_block(block, &mut cx)), - ) -} -pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement { - use ParsedMarkdownElement::*; - match block { - Paragraph(text) => render_markdown_paragraph(text, cx), - Heading(heading) => render_markdown_heading(heading, cx), - ListItem(list_item) => render_markdown_list_item(list_item, cx), - Table(table) => render_markdown_table(table, cx), - BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx), - CodeBlock(code_block) => render_markdown_code_block(code_block, cx), - MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx), - HorizontalRule(_) => render_markdown_rule(cx), - Image(image) => render_markdown_image(image, cx), - } -} - -fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement { - let size = match parsed.level { - HeadingLevel::H1 => 2., - HeadingLevel::H2 => 1.5, - HeadingLevel::H3 => 1.25, - HeadingLevel::H4 => 1., - HeadingLevel::H5 => 0.875, - HeadingLevel::H6 => 0.85, - }; - - let text_size = cx.scaled_rems(size); - - // was `DefiniteLength::from(text_size.mul(1.25))` - // let line_height = DefiniteLength::from(text_size.mul(1.25)); - let line_height = text_size * 1.25; - - // was `rems(0.15)` - // let padding_top = cx.scaled_rems(0.15); - let padding_top = rems(0.15); - - // was `.pb_1()` = `rems(0.25)` - // let padding_bottom = cx.scaled_rems(0.25); - let padding_bottom = rems(0.25); - - let color = match parsed.level { - HeadingLevel::H6 => cx.text_muted_color, - _ => cx.text_color, - }; - div() - .line_height(line_height) - .text_size(text_size) - .text_color(color) - .pt(padding_top) - .pb(padding_bottom) - .children(render_markdown_text(&parsed.contents, cx)) - .whitespace_normal() - .into_any() -} - -fn render_markdown_list_item( - parsed: &ParsedMarkdownListItem, - cx: &mut RenderContext, -) -> AnyElement { - use ParsedMarkdownListItemType::*; - let depth = parsed.depth.saturating_sub(1) as usize; - - let bullet = match &parsed.item_type { - Ordered(order) => list_item_prefix(*order as usize, true, depth).into_any_element(), - Unordered => list_item_prefix(1, false, depth).into_any_element(), - Task(checked, range) => div() - .id(cx.next_id(range)) - .mt(cx.scaled_rems(3.0 / 16.0)) - .child( - MarkdownCheckbox::new( - "checkbox", - if *checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - cx.clone(), - ) - .when_some( - cx.checkbox_clicked_callback.clone(), - |this, callback| { - this.on_click({ - let range = range.clone(); - move |selection, window, cx| { - let checked = match selection { - ToggleState::Selected => true, - ToggleState::Unselected => false, - _ => return, - }; - - if window.modifiers().secondary() { - callback( - &CheckboxClickedEvent { - checked, - source_range: range.clone(), - }, - window, - cx, - ); - } - } - }) - }, - ), - ) - .hover(|s| s.cursor_pointer()) - .tooltip(|_, cx| { - InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into() - }) - .into_any_element(), - }; - let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet); - - let contents: Vec = parsed - .content - .iter() - .map(|c| render_markdown_block(c, cx)) - .collect(); - - let item = h_flex() - .when(!parsed.nested, |this| this.pl(cx.scaled_rems(depth as f32))) - .when(parsed.nested && depth > 0, |this| this.ml_neg_1p5()) - .items_start() - .children(vec![ - bullet, - v_flex() - .children(contents) - .when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0))) - .pr(cx.scaled_rems(1.0)) - .w_full(), - ]); - - cx.with_common_p(item).into_any() -} - -/// # MarkdownCheckbox /// -/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview -/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the -/// app are not visually affected -#[derive(gpui::IntoElement)] -struct MarkdownCheckbox { - id: ElementId, - toggle_state: ToggleState, - disabled: bool, - placeholder: bool, - on_click: Option>, - filled: bool, - style: ui::ToggleStyle, - tooltip: Option gpui::AnyView>>, - label: Option, - base_rem: Rems, -} - -impl MarkdownCheckbox { - /// Creates a new [`Checkbox`]. - fn new(id: impl Into, checked: ToggleState, render_cx: RenderContext) -> Self { - Self { - id: id.into(), - toggle_state: checked, - disabled: false, - on_click: None, - filled: false, - style: ui::ToggleStyle::default(), - tooltip: None, - label: None, - placeholder: false, - base_rem: render_cx.scaled_rems(1.0), - } - } - - /// Binds a handler to the [`Checkbox`] that will be called when clicked. - fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self { - self.on_click = Some(Box::new(handler)); - self - } - - fn bg_color(&self, cx: &App) -> Hsla { - let style = self.style.clone(); - match (style, self.filled) { - (ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background, - (ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background, - (ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(), - (ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx), - (ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(), - (ui::ToggleStyle::Custom(color), true) => color.opacity(0.2), - } - } - - fn border_color(&self, cx: &App) -> Hsla { - if self.disabled { - return cx.theme().colors().border_variant; - } - - match self.style.clone() { - ui::ToggleStyle::Ghost => cx.theme().colors().border, - ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border, - ui::ToggleStyle::Custom(color) => color.opacity(0.3), - } - } -} - -impl gpui::RenderOnce for MarkdownCheckbox { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let group_id = format!("checkbox_group_{:?}", self.id); - let color = if self.disabled { - Color::Disabled - } else { - Color::Selected - }; - let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small - let icon = match self.toggle_state { - ToggleState::Selected => { - if self.placeholder { - None - } else { - Some( - ui::Icon::new(IconName::Check) - .size(icon_size_small) - .color(color), - ) - } - } - ToggleState::Indeterminate => Some( - ui::Icon::new(IconName::Dash) - .size(icon_size_small) - .color(color), - ), - ToggleState::Unselected => None, - }; - - let bg_color = self.bg_color(cx); - let border_color = self.border_color(cx); - let hover_border_color = border_color.alpha(0.7); - - let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px) - - let checkbox = h_flex() - .id(self.id.clone()) - .justify_center() - .items_center() - .size(size) - .group(group_id.clone()) - .child( - div() - .flex() - .flex_none() - .justify_center() - .items_center() - .m(self.base_rem.mul(0.25)) // was .m_1 - .size(self.base_rem.mul(1.0)) // was .size_4 - .rounded(self.base_rem.mul(0.125)) // was .rounded_xs - .border_1() - .bg(bg_color) - .border_color(border_color) - .when(self.disabled, |this| this.cursor_not_allowed()) - .when(self.disabled, |this| { - this.bg(cx.theme().colors().element_disabled.opacity(0.6)) - }) - .when(!self.disabled, |this| { - this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color)) - }) - .when(self.placeholder, |this| { - this.child( - div() - .flex_none() - .rounded_full() - .bg(color.color(cx).alpha(0.5)) - .size(self.base_rem.mul(0.25)), // was .size_1 - ) - }) - .children(icon), - ); - - h_flex() - .id(self.id) - .gap(ui::DynamicSpacing::Base06.rems(cx)) - .child(checkbox) - .when_some( - self.on_click.filter(|_| !self.disabled), - |this, on_click| { - this.on_click(move |_, window, cx| { - on_click(&self.toggle_state.inverse(), window, cx) - }) - }, - ) - // TODO: Allow label size to be different from default. - // TODO: Allow label color to be different from muted. - .when_some(self.label, |this, label| { - this.child(Label::new(label).color(Color::Muted)) - }) - .when_some(self.tooltip, |this, tooltip| { - this.tooltip(move |window, cx| tooltip(window, cx)) - }) - } -} - -fn calculate_table_columns_count(rows: &Vec) -> usize { - let mut actual_column_count = 0; - for row in rows { - actual_column_count = actual_column_count.max( - row.columns - .iter() - .map(|column| column.col_span) - .sum::(), - ); - } - actual_column_count -} - -fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement { - let actual_header_column_count = calculate_table_columns_count(&parsed.header); - let actual_body_column_count = calculate_table_columns_count(&parsed.body); - let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count); - - let total_rows = parsed.header.len() + parsed.body.len(); - - // Track which grid cells are occupied by spanning cells - let mut grid_occupied = vec![vec![false; max_column_count]; total_rows]; - - let mut cells = Vec::with_capacity(total_rows * max_column_count); - - for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() { - let mut col_idx = 0; - - for cell in row.columns.iter() { - // Skip columns occupied by row-spanning cells from previous rows - while col_idx < max_column_count && grid_occupied[row_idx][col_idx] { - col_idx += 1; - } - - if col_idx >= max_column_count { - break; - } - - let container = match cell.alignment { - ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(), - ParsedMarkdownTableAlignment::Center => v_flex().items_center(), - ParsedMarkdownTableAlignment::Right => v_flex().items_end(), - }; - - let cell_element = container - .col_span(cell.col_span.min(max_column_count - col_idx) as u16) - .row_span(cell.row_span.min(total_rows - row_idx) as u16) - .children(render_markdown_text(&cell.children, cx)) - .px_2() - .py_1() - .when(col_idx > 0, |this| this.border_l_1()) - .when(row_idx > 0, |this| this.border_t_1()) - .border_color(cx.border_color) - .when(cell.is_header, |this| { - this.bg(cx.title_bar_background_color) - }) - .when(cell.row_span > 1, |this| this.justify_center()) - .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); - - cells.push(cell_element); - - // Mark grid positions as occupied for row-spanning cells - for r in 0..cell.row_span { - for c in 0..cell.col_span { - if row_idx + r < total_rows && col_idx + c < max_column_count { - grid_occupied[row_idx + r][col_idx + c] = true; - } - } - } - - col_idx += cell.col_span; - } - - // Fill remaining columns with empty cells if needed - while col_idx < max_column_count { - if grid_occupied[row_idx][col_idx] { - col_idx += 1; - continue; - } - - let empty_cell = div() - .when(col_idx > 0, |this| this.border_l_1()) - .when(row_idx > 0, |this| this.border_t_1()) - .border_color(cx.border_color) - .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); - - cells.push(empty_cell); - col_idx += 1; - } - } - - cx.with_common_p(v_flex().items_start()) - .when_some(parsed.caption.as_ref(), |this, caption| { - this.children(render_markdown_text(caption, cx)) - }) - .child( - div() - .rounded_sm() - .overflow_hidden() - .border_1() - .border_color(cx.border_color) - .min_w_0() - .grid() - .grid_cols_max_content(max_column_count as u16) - .children(cells), - ) - .into_any() -} - -fn render_markdown_block_quote( - parsed: &ParsedMarkdownBlockQuote, - cx: &mut RenderContext, -) -> AnyElement { - cx.indent += 1; - - let children: Vec = parsed - .children - .iter() - .enumerate() - .map(|(ix, child)| { - cx.with_last_child(ix + 1 == parsed.children.len(), |cx| { - render_markdown_block(child, cx) - }) - }) - .collect(); - - cx.indent -= 1; - - cx.with_common_p(div()) - .child( - div() - .border_l_4() - .border_color(cx.border_color) - .pl_3() - .children(children), - ) - .into_any() -} - -fn render_markdown_code_block( - parsed: &ParsedMarkdownCodeBlock, - cx: &mut RenderContext, -) -> AnyElement { - let body = if let Some(highlights) = parsed.highlights.as_ref() { - StyledText::new(parsed.contents.clone()).with_default_highlights( - &cx.buffer_text_style, - highlights.iter().filter_map(|(range, highlight_id)| { - cx.syntax_theme - .get(*highlight_id) - .cloned() - .map(|style| (range.clone(), style)) - }), - ) - } else { - StyledText::new(parsed.contents.clone()) - }; - - let copy_block_button = CopyButton::new("copy-codeblock", parsed.contents.clone()) - .tooltip_label("Copy Codeblock") - .visible_on_hover("markdown-block"); - - let font = gpui::Font { - family: cx.buffer_font_family.clone(), - features: cx.buffer_text_style.font_features.clone(), - ..Default::default() - }; - - cx.with_common_p(div()) - .font(font) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child(body) - .child( - div() - .h_flex() - .absolute() - .right_1() - .top_1() - .child(copy_block_button), - ) - .into_any() -} - -fn render_mermaid_diagram( - parsed: &ParsedMarkdownMermaidDiagram, - cx: &mut RenderContext, -) -> AnyElement { - let cached = cx.mermaid_state.cache.get(&parsed.contents); - - if let Some(result) = cached.and_then(|c| c.render_image.get()) { - match result { - Ok(render_image) => cx - .with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - div().w_full().child( - img(ImageSource::Render(render_image.clone())) - .max_w_full() - .with_fallback(|| { - div() - .child(Label::new("Failed to load mermaid diagram")) - .into_any_element() - }), - ), - ) - .into_any(), - Err(_) => cx - .with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child(StyledText::new(parsed.contents.contents.clone())) - .into_any(), - } - } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) { - cx.with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - div() - .w_full() - .child( - img(ImageSource::Render(fallback.clone())) - .max_w_full() - .with_fallback(|| { - div() - .child(Label::new("Failed to load mermaid diagram")) - .into_any_element() - }), - ) - .with_animation( - "mermaid-fallback-pulse", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.0)), - |el, delta| el.opacity(delta), - ), - ) - .into_any() - } else { - cx.with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - Label::new("Rendering mermaid diagram...") - .color(Color::Muted) - .with_animation( - "mermaid-loading-pulse", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.alpha(delta), - ), - ) - .into_any() - } -} - -fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement { - cx.with_common_p(div()) - .children(render_markdown_text(parsed, cx)) - .flex() - .flex_col() - .into_any_element() -} - -fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec { - let mut any_element = Vec::with_capacity(parsed_new.len()); - // these values are cloned in-order satisfy borrow checker - let syntax_theme = cx.syntax_theme.clone(); - let workspace_clone = cx.workspace.clone(); - let code_span_bg_color = cx.code_span_background_color; - let text_style = cx.text_style.clone(); - let link_color = cx.link_color; - - for parsed_region in parsed_new { - match parsed_region { - MarkdownParagraphChunk::Text(parsed) => { - let trimmed = parsed.contents.trim(); - if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" { - let checked = trimmed != "[ ]"; - let element = div() - .child(MarkdownCheckbox::new( - cx.next_id(&parsed.source_range), - if checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - cx.clone(), - )) - .into_any(); - any_element.push(element); - continue; - } - - let element_id = cx.next_id(&parsed.source_range); - - let highlights = gpui::combine_highlights( - parsed.highlights.iter().filter_map(|(range, highlight)| { - highlight - .to_highlight_style(&syntax_theme) - .map(|style| (range.clone(), style)) - }), - parsed.regions.iter().filter_map(|(range, region)| { - if region.code { - Some(( - range.clone(), - HighlightStyle { - background_color: Some(code_span_bg_color), - ..Default::default() - }, - )) - } else if region.link.is_some() { - Some(( - range.clone(), - HighlightStyle { - color: Some(link_color), - ..Default::default() - }, - )) - } else { - None - } - }), - ); - let mut links = Vec::new(); - let mut link_ranges = Vec::new(); - for (range, region) in parsed.regions.iter() { - if let Some(link) = region.link.clone() { - links.push(link); - link_ranges.push(range.clone()); - } - } - let workspace = workspace_clone.clone(); - let element = div() - .child( - InteractiveText::new( - element_id, - StyledText::new(parsed.contents.clone()) - .with_default_highlights(&text_style, highlights), - ) - .tooltip({ - let links = links.clone(); - let link_ranges = link_ranges.clone(); - move |idx, _, cx| { - for (ix, range) in link_ranges.iter().enumerate() { - if range.contains(&idx) { - return Some(LinkPreview::new(&links[ix].to_string(), cx)); - } - } - None - } - }) - .on_click( - link_ranges, - move |clicked_range_ix, window, cx| match &links[clicked_range_ix] { - Link::Web { url } => cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - normalize_path(path.clone().as_path()), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); - }); - } - } - }, - ), - ) - .into_any(); - any_element.push(element); - } - - MarkdownParagraphChunk::Image(image) => { - any_element.push(render_markdown_image(image, cx)); - } - } - } - - any_element -} - -fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement { - let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color); - div().py(cx.scaled_rems(0.5)).child(rule).into_any() -} - -fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement { - let image_resource = match image.link.clone() { - Link::Web { url } => Resource::Uri(url.into()), - Link::Path { path, .. } => Resource::Path(Arc::from(path)), - }; - - let element_id = cx.next_id(&image.source_range); - let workspace = cx.workspace.clone(); - - div() - .id(element_id) - .cursor_pointer() - .child( - img(ImageSource::Resource(image_resource)) - .max_w_full() - .with_fallback({ - let alt_text = image.alt_text.clone(); - move || div().children(alt_text.clone()).into_any_element() - }) - .when_some(image.height, |this, height| this.h(height)) - .when_some(image.width, |this, width| this.w(width)), - ) - .tooltip({ - let link = image.link.clone(); - let alt_text = image.alt_text.clone(); - move |_, cx| { - InteractiveMarkdownElementTooltip::new( - Some(alt_text.clone().unwrap_or(link.to_string().into())), - "open image", - cx, - ) - .into() - } - }) - .on_click({ - let link = image.link.clone(); - move |_, window, cx| { - if window.modifiers().secondary() { - match &link { - Link::Web { url } => cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - path.clone(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); - }); - } - } - } - } - } - }) - .into_any() -} - -struct InteractiveMarkdownElementTooltip { - tooltip_text: Option, - action_text: SharedString, -} - -impl InteractiveMarkdownElementTooltip { - pub fn new( - tooltip_text: Option, - action_text: impl Into, - cx: &mut App, - ) -> Entity { - let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into()); - - cx.new(|_cx| Self { - tooltip_text, - action_text: action_text.into(), - }) - } -} - -impl Render for InteractiveMarkdownElementTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |el, _| { - let secondary_modifier = Keystroke { - modifiers: Modifiers::secondary_key(), - ..Default::default() - }; - - el.child( - v_flex() - .gap_1() - .when_some(self.tooltip_text.clone(), |this, text| { - this.child(Label::new(text).size(LabelSize::Small)) - }) - .child( - Label::new(format!( - "{}-click to {}", - secondary_modifier, self.action_text - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - } -} - -/// Returns the prefix for a list item. -fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String { - let ix = order.saturating_sub(1); - const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz"; - const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"]; - - if ordered { - match depth { - 0 => format!("{}. ", order), - 1 => format!( - "{}. ", - NUMBERED_PREFIXES_1 - .chars() - .nth(ix % NUMBERED_PREFIXES_1.len()) - .unwrap() - ), - _ => format!( - "{}. ", - NUMBERED_PREFIXES_2 - .chars() - .nth(ix % NUMBERED_PREFIXES_2.len()) - .unwrap() - ), - } - } else { - let depth = depth.min(BULLETS.len() - 1); - let bullet = BULLETS[depth]; - return format!("{} ", bullet); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents; - use crate::markdown_elements::ParsedMarkdownTableColumn; - use crate::markdown_elements::ParsedMarkdownText; - - fn text(text: &str) -> MarkdownParagraphChunk { - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..text.len(), - contents: SharedString::new(text), - highlights: Default::default(), - regions: Default::default(), - }) - } - - fn column( - col_span: usize, - row_span: usize, - children: Vec, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header: false, - children, - alignment: ParsedMarkdownTableAlignment::None, - } - } - - fn column_with_row_span( - col_span: usize, - row_span: usize, - children: Vec, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header: false, - children, - alignment: ParsedMarkdownTableAlignment::None, - } - } - - #[test] - fn test_calculate_table_columns_count() { - assert_eq!(0, calculate_table_columns_count(&vec![])); - - assert_eq!( - 1, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]) - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(2, 1, vec![text("column1")]) - ])]) - ); - - assert_eq!( - 3, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(2, 1, vec![text("column2")]), - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),]) - ]) - ); - - assert_eq!( - 3, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),]) - ]) - ); - } - - #[test] - fn test_row_span_support() { - assert_eq!( - 3, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column_with_row_span(1, 2, vec![text("spans 2 rows")]), - column(1, 1, vec![text("column2")]), - column(1, 1, vec![text("column3")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column is covered by row span from above - column(1, 1, vec![text("column2 row2")]), - column(1, 1, vec![text("column3 row2")]), - ]) - ]) - ); - - assert_eq!( - 4, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column_with_row_span(1, 3, vec![text("spans 3 rows")]), - column_with_row_span(2, 1, vec![text("spans 2 cols")]), - column(1, 1, vec![text("column4")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column covered by row span - column(1, 1, vec![text("column2")]), - column(1, 1, vec![text("column3")]), - column(1, 1, vec![text("column4")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column still covered by row span - column(3, 1, vec![text("spans 3 cols")]), - ]) - ]) - ); - } - - #[test] - fn test_list_item_prefix() { - assert_eq!(list_item_prefix(1, true, 0), "1. "); - assert_eq!(list_item_prefix(2, true, 0), "2. "); - assert_eq!(list_item_prefix(3, true, 0), "3. "); - assert_eq!(list_item_prefix(11, true, 0), "11. "); - assert_eq!(list_item_prefix(1, true, 1), "A. "); - assert_eq!(list_item_prefix(2, true, 1), "B. "); - assert_eq!(list_item_prefix(3, true, 1), "C. "); - assert_eq!(list_item_prefix(1, true, 2), "a. "); - assert_eq!(list_item_prefix(2, true, 2), "b. "); - assert_eq!(list_item_prefix(7, true, 2), "g. "); - assert_eq!(list_item_prefix(1, true, 1), "A. "); - assert_eq!(list_item_prefix(1, true, 2), "a. "); - assert_eq!(list_item_prefix(1, false, 0), "• "); - assert_eq!(list_item_prefix(1, false, 1), "◦ "); - assert_eq!(list_item_prefix(1, false, 2), "▪ "); - assert_eq!(list_item_prefix(1, false, 3), "‣ "); - assert_eq!(list_item_prefix(1, false, 4), "⁃ "); - } - - fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents { - ParsedMarkdownMermaidDiagramContents { - contents: SharedString::from(s.to_string()), - scale: 1, - } - } - - fn mermaid_sequence(diagrams: &[&str]) -> Vec { - diagrams - .iter() - .map(|diagram| mermaid_contents(diagram)) - .collect() - } - - fn mermaid_fallback( - new_diagram: &str, - new_full_order: &[ParsedMarkdownMermaidDiagramContents], - old_full_order: &[ParsedMarkdownMermaidDiagramContents], - cache: &MermaidDiagramCache, - ) -> Option> { - let new_content = mermaid_contents(new_diagram); - let idx = new_full_order - .iter() - .position(|content| content == &new_content)?; - MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache) - } - - fn mock_render_image() -> Arc { - Arc::new(RenderImage::new(Vec::new())) - } - - #[test] - fn test_mermaid_fallback_on_edit() { - let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); - - let svg_b = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = - mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_b), - "Fallback should be the old diagram's SVG" - ); - } - - #[test] - fn test_mermaid_no_fallback_on_add_in_middle() { - let old_full_order = mermaid_sequence(&["graph A", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]); - - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_none(), - "Should NOT use fallback when adding new diagram" - ); - } - - #[test] - fn test_mermaid_fallback_chains_on_rapid_edits() { - let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]); - - let original_svg = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph B modified"), - // Still rendering, but has fallback from original "graph B" - CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback( - "graph B modified again", - &new_full_order, - &old_full_order, - &cache, - ); - - assert!( - fallback.is_some(), - "Should chain fallback when previous render not complete" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &original_svg), - "Fallback should chain through to the original SVG" - ); - } - - #[test] - fn test_mermaid_no_fallback_when_no_old_diagram_at_index() { - let old_full_order = mermaid_sequence(&["graph A"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B"]); - - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_none(), - "Should NOT have fallback when adding diagram at end" - ); - } - - #[test] - fn test_mermaid_fallback_with_duplicate_blocks_edit_first() { - let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); - let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]); - - let svg_a = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing one of duplicate blocks" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_a), - "Fallback should be the old duplicate diagram's image" - ); - } - - #[test] - fn test_mermaid_fallback_with_duplicate_blocks_edit_second() { - let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); - let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]); - - let svg_a = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing the second duplicate block" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_a), - "Fallback should be the old duplicate diagram's image" - ); - } -}