From 9efe3c5a215b0d5164f0426b670f6577d9f9839e Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 26 Mar 2026 12:27:39 +0530 Subject: [PATCH] markdown_preview: Refactor to use shared markdown crate (#52008) We now use the parser and renderer from the `markdown` crate for Markdown Preview, instead of maintaining two separate code paths. How it works: `markdown_preview_view.rs` is now a consumer of `MarkdownElement`. It acts as a thin wrapper, handling things like resolving URL clicks and image URLs, which can vary between consumers. It also handles syncing the editor selection with the active block in the preview. The APIs for this are provided by `MarkdownElement`. All the heavy lifting like parsing HTML, rendering block markers on hover, handling the active block, etc. is done by `MarkdownElement`. Everything is opt-in. For example, markdown in the Agent Panel can choose not to enable block marker rendering or HTML parsing, while Markdown Preview opts into those features. Final outcome: For Markdown Preview View: - Added: - Selection support in the preview - Stays: - Syncing between editor and preview - Autoscroll - Hover and active block markers - Checkbox toggling - Image rendering - Mermaid rendering For the `markdown` crate: - No changes for existing consumers like the Agent Panel - Consumers can now opt into: - HTML rendering - Block marker rendering - Click event handling - Custom image resolvers - Mermaid rendering Release Notes: - N/A --- Cargo.lock | 15 +- crates/markdown/Cargo.toml | 5 + crates/markdown/src/html.rs | 3 + .../src/html/html_minifier.rs} | 0 crates/markdown/src/html/html_parser.rs | 883 +++++ crates/markdown/src/html/html_rendering.rs | 613 +++ crates/markdown/src/markdown.rs | 663 +++- crates/markdown/src/mermaid.rs | 614 +++ crates/markdown/src/parser.rs | 370 +- crates/markdown_preview/Cargo.toml | 12 +- .../markdown_preview/src/markdown_elements.rs | 374 -- .../markdown_preview/src/markdown_parser.rs | 3320 ----------------- .../markdown_preview/src/markdown_preview.rs | 4 - .../src/markdown_preview_view.rs | 625 ++-- .../markdown_preview/src/markdown_renderer.rs | 1517 -------- 15 files changed, 3332 insertions(+), 5686 deletions(-) create mode 100644 crates/markdown/src/html.rs rename crates/{markdown_preview/src/markdown_minifier.rs => markdown/src/html/html_minifier.rs} (100%) create mode 100644 crates/markdown/src/html/html_parser.rs create mode 100644 crates/markdown/src/html/html_rendering.rs create mode 100644 crates/markdown/src/mermaid.rs delete mode 100644 crates/markdown_preview/src/markdown_elements.rs delete mode 100644 crates/markdown_preview/src/markdown_parser.rs delete mode 100644 crates/markdown_preview/src/markdown_renderer.rs 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" - ); - } -}