From d362588055f11c18c43d3f104dd619b82b9b2721 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> Date: Tue, 12 Mar 2024 11:54:12 +0100 Subject: [PATCH] markdown preview: highlight code blocks (#9087) ![image](https://github.com/zed-industries/zed/assets/53836821/e20acd87-9680-4e1c-818d-7ae900bf0e31) Release Notes: - Added syntax highlighting to code blocks in markdown preview - Fixed scroll position in markdown preview when editing a markdown file (#9208) --- Cargo.lock | 1 + Cargo.toml | 1 + crates/auto_update/src/auto_update.rs | 2 + crates/markdown_preview/Cargo.toml | 1 + .../markdown_preview/src/markdown_elements.rs | 1 + .../markdown_preview/src/markdown_parser.rs | 223 ++++++++++++------ .../src/markdown_preview_view.rs | 143 +++++++---- .../markdown_preview/src/markdown_renderer.rs | 16 +- crates/workspace/Cargo.toml | 2 +- 9 files changed, 264 insertions(+), 126 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c515fff45c..cf51b63dea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5643,6 +5643,7 @@ dependencies = [ name = "markdown_preview" version = "0.1.0" dependencies = [ + "async-recursion 1.0.5", "editor", "gpui", "language", diff --git a/Cargo.toml b/Cargo.toml index daba782b13..6ad0abd85b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -198,6 +198,7 @@ zed_actions = { path = "crates/zed_actions" } anyhow = "1.0.57" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } +async-recursion = "1.0.0" async-tar = "0.4.2" async-trait = "0.1" bitflags = "2.4.2" diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index f364304d59..f3acdecd48 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -229,6 +229,7 @@ fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext, pub language: Option, pub contents: SharedString, + pub highlights: Option, HighlightId)>>, } #[derive(Debug)] diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index 87e3266a22..c3733300e2 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -1,16 +1,23 @@ use crate::markdown_elements::*; +use async_recursion::async_recursion; use gpui::FontWeight; +use language::LanguageRegistry; use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd}; -use std::{ops::Range, path::PathBuf}; +use std::{ops::Range, path::PathBuf, sync::Arc}; -pub fn parse_markdown( +pub async fn parse_markdown( markdown_input: &str, file_location_directory: Option, + language_registry: Option>, ) -> ParsedMarkdown { let options = Options::all(); let parser = Parser::new_ext(markdown_input, options); - let parser = MarkdownParser::new(parser.into_offset_iter().collect(), file_location_directory); - let renderer = parser.parse_document(); + let parser = MarkdownParser::new( + parser.into_offset_iter().collect(), + file_location_directory, + language_registry, + ); + let renderer = parser.parse_document().await; ParsedMarkdown { children: renderer.parsed, } @@ -23,16 +30,19 @@ struct MarkdownParser<'a> { /// The blocks that we have successfully parsed so far parsed: Vec, file_location_directory: Option, + language_registry: Option>, } 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![], } @@ -81,16 +91,16 @@ impl<'a> MarkdownParser<'a> { } } - fn parse_document(mut self) -> Self { + async fn parse_document(mut self) -> Self { while !self.eof() { - if let Some(block) = self.parse_block() { + if let Some(block) = self.parse_block().await { self.parsed.push(block); } } self } - fn parse_block(&mut self) -> Option { + async fn parse_block(&mut self) -> Option { let (current, source_range) = self.current().unwrap(); match current { Event::Start(tag) => match tag { @@ -119,12 +129,12 @@ impl<'a> MarkdownParser<'a> { Tag::List(order) => { let order = *order; self.cursor += 1; - let list = self.parse_list(1, order); + let list = self.parse_list(1, order).await; Some(ParsedMarkdownElement::List(list)) } Tag::BlockQuote => { self.cursor += 1; - let block_quote = self.parse_block_quote(); + let block_quote = self.parse_block_quote().await; Some(ParsedMarkdownElement::BlockQuote(block_quote)) } Tag::CodeBlock(kind) => { @@ -141,7 +151,7 @@ impl<'a> MarkdownParser<'a> { self.cursor += 1; - let code_block = self.parse_code_block(language); + let code_block = self.parse_code_block(language).await; Some(ParsedMarkdownElement::CodeBlock(code_block)) } _ => { @@ -407,7 +417,8 @@ impl<'a> MarkdownParser<'a> { } } - fn parse_list(&mut self, depth: u16, order: Option) -> ParsedMarkdownList { + #[async_recursion] + async fn parse_list(&mut self, depth: u16, order: Option) -> ParsedMarkdownList { let (_event, source_range) = self.previous().unwrap(); let source_range = source_range.clone(); let mut children = vec![]; @@ -424,7 +435,7 @@ impl<'a> MarkdownParser<'a> { let order = *order; self.cursor += 1; - let inner_list = self.parse_list(depth + 1, order); + let inner_list = self.parse_list(depth + 1, order).await; let block = ParsedMarkdownElement::List(inner_list); current_list_items.push(Box::new(block)); } @@ -455,7 +466,7 @@ impl<'a> MarkdownParser<'a> { let block = ParsedMarkdownElement::Paragraph(text); current_list_items.push(Box::new(block)); } else { - let block = self.parse_block(); + let block = self.parse_block().await; if let Some(block) = block { current_list_items.push(Box::new(block)); } @@ -493,7 +504,7 @@ impl<'a> MarkdownParser<'a> { break; } - let block = self.parse_block(); + let block = self.parse_block().await; if let Some(block) = block { current_list_items.push(Box::new(block)); } @@ -507,7 +518,8 @@ impl<'a> MarkdownParser<'a> { } } - fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote { + #[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; @@ -515,7 +527,7 @@ impl<'a> MarkdownParser<'a> { let mut children: Vec> = vec![]; while !self.eof() { - let block = self.parse_block(); + let block = self.parse_block().await; if let Some(block) = block { children.push(Box::new(block)); @@ -553,7 +565,7 @@ impl<'a> MarkdownParser<'a> { } } - fn parse_code_block(&mut self, language: Option) -> ParsedMarkdownCodeBlock { + async fn parse_code_block(&mut self, language: Option) -> ParsedMarkdownCodeBlock { let (_event, source_range) = self.previous().unwrap(); let source_range = source_range.clone(); let mut code = String::new(); @@ -575,10 +587,26 @@ impl<'a> MarkdownParser<'a> { } } + 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 + }; + ParsedMarkdownCodeBlock { source_range, contents: code.trim().to_string().into(), language, + highlights, } } } @@ -587,18 +615,20 @@ impl<'a> MarkdownParser<'a> { mod tests { use super::*; + use gpui::BackgroundExecutor; + use language::{tree_sitter_rust, HighlightId, Language, LanguageConfig, LanguageMatcher}; use pretty_assertions::assert_eq; use ParsedMarkdownElement::*; use ParsedMarkdownListItemType::*; - fn parse(input: &str) -> ParsedMarkdown { - parse_markdown(input, None) + async fn parse(input: &str) -> ParsedMarkdown { + parse_markdown(input, None, None).await } - #[test] - fn test_headings() { - let parsed = parse("# Heading one\n## Heading two\n### Heading three"); + #[gpui::test] + async fn test_headings() { + let parsed = parse("# Heading one\n## Heading two\n### Heading three").await; assert_eq!( parsed.children, @@ -610,9 +640,9 @@ mod tests { ); } - #[test] - fn test_newlines_dont_new_paragraphs() { - let parsed = parse("Some text **that is bolded**\n and *italicized*"); + #[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, @@ -620,9 +650,9 @@ mod tests { ); } - #[test] - fn test_heading_with_paragraph() { - let parsed = parse("# Zed\nThe editor"); + #[gpui::test] + async fn test_heading_with_paragraph() { + let parsed = parse("# Zed\nThe editor").await; assert_eq!( parsed.children, @@ -630,9 +660,9 @@ mod tests { ); } - #[test] - fn test_double_newlines_do_new_paragraphs() { - let parsed = parse("Some text **that is bolded**\n\n and *italicized*"); + #[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, @@ -643,9 +673,9 @@ mod tests { ); } - #[test] - fn test_bold_italic_text() { - let parsed = parse("Some text **that is bolded** and *italicized*"); + #[gpui::test] + async fn test_bold_italic_text() { + let parsed = parse("Some text **that is bolded** and *italicized*").await; assert_eq!( parsed.children, @@ -653,9 +683,9 @@ mod tests { ); } - #[test] - fn test_nested_bold_strikethrough_text() { - let parsed = parse("Some **bo~~strikethrough~~ld** text"); + #[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!( @@ -703,8 +733,8 @@ mod tests { ); } - #[test] - fn test_header_only_table() { + #[gpui::test] + async fn test_header_only_table() { let markdown = "\ | Header 1 | Header 2 | |----------|----------| @@ -719,13 +749,13 @@ Some other content ); assert_eq!( - parse(markdown).children[0], + parse(markdown).await.children[0], ParsedMarkdownElement::Table(expected_table) ); } - #[test] - fn test_basic_table() { + #[gpui::test] + async fn test_basic_table() { let markdown = "\ | Header 1 | Header 2 | |----------|----------| @@ -742,20 +772,21 @@ Some other content ); assert_eq!( - parse(markdown).children[0], + parse(markdown).await.children[0], ParsedMarkdownElement::Table(expected_table) ); } - #[test] - fn test_list_basic() { + #[gpui::test] + async fn test_list_basic() { let parsed = parse( "\ * Item 1 * Item 2 * Item 3 ", - ); + ) + .await; assert_eq!( parsed.children, @@ -770,14 +801,15 @@ Some other content ); } - #[test] - fn test_list_with_tasks() { + #[gpui::test] + async fn test_list_with_tasks() { let parsed = parse( "\ - [ ] TODO - [x] Checked ", - ); + ) + .await; assert_eq!( parsed.children, @@ -791,8 +823,8 @@ Some other content ); } - #[test] - fn test_list_nested() { + #[gpui::test] + async fn test_list_nested() { let parsed = parse( "\ * Item 1 @@ -813,7 +845,8 @@ Some other content 2. Goodbyte * Last ", - ); + ) + .await; assert_eq!( parsed.children, @@ -900,14 +933,15 @@ Some other content ); } - #[test] - fn test_list_with_nested_content() { + #[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, @@ -925,15 +959,16 @@ Some other content ); } - #[test] - fn test_list_with_leading_text() { + #[gpui::test] + async fn test_list_with_leading_text() { let parsed = parse( "\ * `code` * **bold** * [link](https://example.com) ", - ); + ) + .await; assert_eq!( parsed.children, @@ -948,9 +983,9 @@ Some other content ); } - #[test] - fn test_simple_block_quote() { - let parsed = parse("> Simple block quote with **styled text**"); + #[gpui::test] + async fn test_simple_block_quote() { + let parsed = parse("> Simple block quote with **styled text**").await; assert_eq!( parsed.children, @@ -961,8 +996,8 @@ Some other content ); } - #[test] - fn test_simple_block_quote_with_multiple_lines() { + #[gpui::test] + async fn test_simple_block_quote_with_multiple_lines() { let parsed = parse( "\ > # Heading @@ -971,7 +1006,8 @@ Some other content > > More text ", - ); + ) + .await; assert_eq!( parsed.children, @@ -986,8 +1022,8 @@ Some other content ); } - #[test] - fn test_nested_block_quote() { + #[gpui::test] + async fn test_nested_block_quote() { let parsed = parse( "\ > A @@ -998,7 +1034,8 @@ Some other content More text ", - ); + ) + .await; assert_eq!( parsed.children, @@ -1016,8 +1053,8 @@ More text ); } - #[test] - fn test_code_block() { + #[gpui::test] + async fn test_code_block() { let parsed = parse( "\ ``` @@ -1026,17 +1063,28 @@ fn main() { } ``` ", - ); + ) + .await; assert_eq!( parsed.children, - vec![code_block(None, "fn main() {\n return 0;\n}", 0..35)] + vec![code_block( + None, + "fn main() {\n return 0;\n}", + 0..35, + None + )] ); } - #[test] - fn test_code_block_with_language() { - let parsed = parse( + #[gpui::test] + async fn test_code_block_with_language(executor: BackgroundExecutor) { + let mut language_registry = LanguageRegistry::test(); + language_registry.set_executor(executor); + let language_registry = Arc::new(language_registry); + language_registry.add(rust_lang()); + + let parsed = parse_markdown( "\ ```rust fn main() { @@ -1044,18 +1092,37 @@ fn main() { } ``` ", - ); + None, + Some(language_registry), + ) + .await; assert_eq!( parsed.children, vec![code_block( - Some("rust".into()), + Some("rust".to_string()), "fn main() {\n return 0;\n}", - 0..39 + 0..39, + Some(vec![]) )] ); } + fn rust_lang() -> Arc { + Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..Default::default() + }, + collapsed_placeholder: " /* ... */ ".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::language()), + )) + } + fn h1(contents: ParsedMarkdownText, source_range: Range) -> ParsedMarkdownElement { ParsedMarkdownElement::Heading(ParsedMarkdownHeading { source_range, @@ -1108,11 +1175,13 @@ fn main() { language: Option, code: &str, source_range: Range, + highlights: Option, HighlightId)>>, ) -> ParsedMarkdownElement { ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock { source_range, language, contents: code.to_string().into(), + highlights, }) } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 50eb958987..10382e8bce 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use std::{ops::Range, path::PathBuf}; use editor::{Editor, EditorEvent}; @@ -5,6 +6,7 @@ use gpui::{ list, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, ListState, ParentElement, Render, Styled, View, ViewContext, WeakView, }; +use language::LanguageRegistry; use ui::prelude::*; use workspace::item::{Item, ItemHandle}; use workspace::Workspace; @@ -19,7 +21,7 @@ use crate::{ pub struct MarkdownPreviewView { workspace: WeakView, focus_handle: FocusHandle, - contents: ParsedMarkdown, + contents: Option, selected_block: usize, list_state: ListState, tab_description: String, @@ -34,10 +36,16 @@ impl MarkdownPreviewView { } if let Some(editor) = workspace.active_item_as::(cx) { + let language_registry = workspace.project().read(cx).languages().clone(); let workspace_handle = workspace.weak_handle(); let tab_description = editor.tab_description(0, cx); - let view: View = - MarkdownPreviewView::new(editor, workspace_handle, tab_description, cx); + let view: View = MarkdownPreviewView::new( + editor, + workspace_handle, + tab_description, + language_registry, + cx, + ); workspace.split_item(workspace::SplitDirection::Right, Box::new(view.clone()), cx); cx.notify(); } @@ -48,55 +56,82 @@ impl MarkdownPreviewView { active_editor: View, workspace: WeakView, tab_description: Option, + language_registry: Arc, cx: &mut ViewContext, ) -> View { cx.new_view(|cx: &mut ViewContext| { let view = cx.view().downgrade(); let editor = active_editor.read(cx); - let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx); let contents = editor.buffer().read(cx).snapshot(cx).text(); - let contents = parse_markdown(&contents, file_location); - cx.subscribe(&active_editor, |this, editor, event: &EditorEvent, cx| { - match event { - EditorEvent::Edited => { - 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); - this.contents = parse_markdown(&contents, file_location); - this.list_state.reset(this.contents.children.len()); - cx.notify(); + let language_registry_copy = language_registry.clone(); + cx.spawn(|view, mut cx| async move { + let contents = + parse_markdown(&contents, file_location, Some(language_registry_copy)).await; - // TODO: This does not work as expected. - // The scroll request appears to be dropped - // after `.reset` is called. - this.list_state.scroll_to_reveal_item(this.selected_block); - cx.notify(); - } - EditorEvent::SelectionsChanged { .. } => { - let editor = editor.read(cx); - let selection_range = editor.selections.last::(cx).range(); - this.selected_block = this.get_block_index_under_cursor(selection_range); - this.list_state.scroll_to_reveal_item(this.selected_block); - cx.notify(); - } - _ => {} - }; + view.update(&mut cx, |view, cx| { + let markdown_blocks_count = contents.children.len(); + view.contents = Some(contents); + view.list_state.reset(markdown_blocks_count); + cx.notify(); + }) }) .detach(); - let list_state = ListState::new( - contents.children.len(), - gpui::ListAlignment::Top, - px(1000.), - move |ix, cx| { + cx.subscribe( + &active_editor, + move |this, editor, event: &EditorEvent, cx| { + match event { + EditorEvent::Edited => { + 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); + let language_registry = language_registry.clone(); + cx.spawn(move |view, mut cx| async move { + let contents = parse_markdown( + &contents, + file_location, + Some(language_registry.clone()), + ) + .await; + view.update(&mut cx, move |view, 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); + cx.notify(); + }) + }) + .detach(); + } + EditorEvent::SelectionsChanged { .. } => { + let editor = editor.read(cx); + let selection_range = editor.selections.last::(cx).range(); + this.selected_block = + this.get_block_index_under_cursor(selection_range); + this.list_state.scroll_to_reveal_item(this.selected_block); + cx.notify(); + } + _ => {} + }; + }, + ) + .detach(); + + let list_state = + ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| { if let Some(view) = view.upgrade() { view.update(cx, |view, cx| { + let Some(contents) = &view.contents else { + return div().into_any(); + }; let mut render_cx = RenderContext::new(Some(view.workspace.clone()), cx); - let block = view.contents.children.get(ix).unwrap(); + let block = contents.children.get(ix).unwrap(); let block = render_markdown_block(block, &mut render_cx); let block = div().child(block).pl_4().pb_3(); @@ -119,8 +154,7 @@ impl MarkdownPreviewView { } else { div().into_any() } - }, - ); + }); let tab_description = tab_description .map(|tab_description| format!("Preview {}", tab_description)) @@ -130,9 +164,9 @@ impl MarkdownPreviewView { selected_block: 0, focus_handle: cx.focus_handle(), workspace, - contents, + contents: None, list_state, - tab_description: tab_description, + tab_description, } }) } @@ -154,18 +188,33 @@ impl MarkdownPreviewView { } fn get_block_index_under_cursor(&self, selection_range: Range) -> usize { - let mut block_index = 0; + let mut block_index = None; let cursor = selection_range.start; - for (i, block) in self.contents.children.iter().enumerate() { - let Range { start, end } = block.source_range(); - if start <= cursor && end >= cursor { - block_index = i; - break; + let mut last_end = 0; + if let Some(content) = &self.contents { + for (i, block) in content.children.iter().enumerate() { + let Range { start, end } = block.source_range(); + + // 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)); } } - return block_index; + block_index.unwrap_or_default() } } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index a4f5eeb711..1907664e5b 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -248,11 +248,25 @@ 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_highlights( + &cx.text_style, + highlights.iter().filter_map(|(range, highlight_id)| { + highlight_id + .style(cx.syntax_theme.as_ref()) + .map(|style| (range.clone(), style)) + }), + ) + } else { + StyledText::new(parsed.contents.clone()) + }; + cx.with_common_p(div()) .px_3() .py_3() .bg(cx.code_block_background_color) - .child(StyledText::new(parsed.contents.clone())) + .rounded_md() + .child(body) .into_any() } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index db8d004b9e..56d52f3d43 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -25,7 +25,7 @@ test-support = [ [dependencies] anyhow.workspace = true -async-recursion = "1.0.0" +async-recursion.workspace = true bincode = "1.2.1" call.workspace = true client.workspace = true