// markdown treesitter playground: https://ikatyang.github.io/tree-sitter-markdown/ use std::ops::Range; use gpui::{AppContext, ParentElement, Refineable, RenderOnce, Styled, div, px, rems}; use tree_sitter::Node; use crate::{app, theme}; const MARKDOWN_KIND_ID_SETEXT_H1_UNDERLINE: u16 = 8; const MARKDOWN_KIND_ID_SETEXT_H2_UNDERLINE: u16 = 9; const MARKDOWN_KIND_ID_ATX_H1_MARKER: u16 = 11; const MARKDOWN_KIND_ID_ATX_H2_MARKER: u16 = 12; const MARKDOWN_KIND_ID_ATX_H3_MARKER: u16 = 13; const MARKDOWN_KIND_ID_ATX_H4_MARKER: u16 = 14; const MARKDOWN_KIND_ID_ATX_H5_MARKER: u16 = 15; const MARKDOWN_KIND_ID_ATX_H6_MARKER: u16 = 16; const MARKDOWN_KIND_ID_LIST_MARKER: u16 = 48; const MARKDOWN_KIND_ID_BACKSLASH_ESCAPE: u16 = 56; const MARKDOWN_KIND_ID_CHARACTER_REFERENCE: u16 = 57; const MARKDOWN_KIND_ID_TABLE_COLUMN_ALIGNMENT: u16 = 107; const MARKDOWN_KIND_ID_HARD_LINE_BREAK: u16 = 110; const MARKDOWN_KIND_ID_SOFT_LINE_BREAK: u16 = 111; const MARKDOWN_KIND_ID_HTML_TAG_NAME: u16 = 117; const MARKDOWN_KIND_ID_VIRTUAL_SPACE: u16 = 118; const MARKDOWN_KIND_ID_DOCUMENT: u16 = 119; const MARKDOWN_KIND_ID_THEMATIC_BREAK: u16 = 122; const MARKDOWN_KIND_ID_PARAGRAPH: u16 = 124; const MARKDOWN_KIND_ID_LINK_REFERENCE_DEFINITION: u16 = 126; const MARKDOWN_KIND_ID_SETEXT_HEADING: u16 = 129; const MARKDOWN_KIND_ID_ATX_HEADING: u16 = 132; const MARKDOWN_KIND_ID_INDENTED_CODE_BLOCK: u16 = 134; const MARKDOWN_KIND_ID_FENCED_CODE_BLOCK: u16 = 136; const MARKDOWN_KIND_ID_CODE_FENCE_CONTENT: u16 = 138; const MARKDOWN_KIND_ID_HTML_BLOCK_SCRIPT: u16 = 140; const MARKDOWN_KIND_ID_HTML_BLOCK_COMMENT: u16 = 142; const MARKDOWN_KIND_ID_HTML_BLOCK_PROCESSING: u16 = 144; const MARKDOWN_KIND_ID_HTML_BLOCK_DECLARATION: u16 = 146; const MARKDOWN_KIND_ID_HTML_BLOCK_CDATA: u16 = 148; const MARKDOWN_KIND_ID_HTML_BLOCK_DIV: u16 = 150; const MARKDOWN_KIND_ID_HTML_BLOCK_CMP: u16 = 152; const MARKDOWN_KIND_ID_BLOCK_QUOTE: u16 = 154; const MARKDOWN_KIND_ID_TIGHT_LIST: u16 = 156; const MARKDOWN_KIND_ID_LOOSE_LIST: u16 = 158; const MARKDOWN_KIND_ID_LIST_ITEM_TIGHT: u16 = 160; const MARKDOWN_KIND_ID_TASK_LIST_ITEM_TIGHT: u16 = 161; const MARKDOWN_KIND_ID_LIST_ITEM_LOOSE: u16 = 163; const MARKDOWN_KIND_ID_TASK_LIST_ITEM_LOOSE: u16 = 164; const MARKDOWN_KIND_ID_PARAGRAPH_TASK_LIST: u16 = 166; const MARKDOWN_KIND_ID_SETEXT_HEADING_TASK_LIST: u16 = 168; const MARKDOWN_KIND_ID_HEADING_CONTENT: u16 = 169; const MARKDOWN_KIND_ID_TABLE: u16 = 170; const MARKDOWN_KIND_ID_TABLE_HEADER_ROW: u16 = 172; const MARKDOWN_KIND_ID_TABLE_DELIMITER_ROW: u16 = 174; const MARKDOWN_KIND_ID_TABLE_DATA_ROW: u16 = 176; const MARKDOWN_KIND_ID_EMPHASIS: u16 = 181; const MARKDOWN_KIND_ID_STRONG_EMPHASIS: u16 = 182; const MARKDOWN_KIND_ID_STRIKETHROUGH: u16 = 183; const MARKDOWN_KIND_ID_LINK: u16 = 184; const MARKDOWN_KIND_ID_IMAGE: u16 = 185; const MARKDOWN_KIND_ID_LINK_DESTINATION: u16 = 190; const MARKDOWN_KIND_ID_LINK_TITLE: u16 = 191; const MARKDOWN_KIND_ID_WWW_AUTOLINK: u16 = 192; const MARKDOWN_KIND_ID_URI_AUTOLINK_EXTENDED: u16 = 194; const MARKDOWN_KIND_ID_EMAIL_AUTOLINK_EXTENDED: u16 = 196; const MARKDOWN_KIND_ID_URI_AUTOLINK_ANGLE: u16 = 198; const MARKDOWN_KIND_ID_EMAIL_AUTOLINK_ANGLE: u16 = 199; const MARKDOWN_KIND_ID_CODE_SPAN: u16 = 200; const MARKDOWN_KIND_ID_HTML_OPEN_TAG: u16 = 201; const MARKDOWN_KIND_ID_HTML_SELF_CLOSING_TAG: u16 = 202; const MARKDOWN_KIND_ID_HTML_CLOSE_TAG: u16 = 204; const MARKDOWN_KIND_ID_HTML_COMMENT: u16 = 205; const MARKDOWN_KIND_ID_HTML_PROCESSING_INSTRUCTION: u16 = 206; const MARKDOWN_KIND_ID_HTML_DECLARATION: u16 = 207; const MARKDOWN_KIND_ID_HTML_CDATA_SECTION: u16 = 208; const MARKDOWN_KIND_ID_HTML_ATTRRIBUTE: u16 = 209; const MARKDOWN_KIND_ID_HTML_ATTRIBUTE_VALUE: u16 = 210; const MARKDOWN_KIND_ID_TEXT: u16 = 211; const MARKDOWN_KIND_ID_HTML_ATTRIBUTE_KEY: u16 = 228; const MARKDOWN_KIND_ID_HTML_DECLARATION_NAME: u16 = 229; const MARKDOWN_KIND_ID_IMAGE_DESCRIPTION: u16 = 230; const MARKDOWN_KIND_ID_INFO_STRING: u16 = 231; const MARKDOWN_KIND_ID_LINE_BREAK: u16 = 232; const MARKDOWN_KIND_ID_LINK_LABEL: u16 = 233; const MARKDOWN_KIND_ID_LINK_TEXT: u16 = 234; const MARKDOWN_KIND_ID_TABLE_CELL: u16 = 235; const MARKDOWN_KIND_ID_TASK_LIST_ITEM_MARKER: u16 = 236; pub(crate) struct MarkdownText { content: gpui::SharedString, blocks: Vec, } enum ContentBlock { Text { decoration: Option<&'static str>, text: gpui::SharedString, highlights: Vec<(Range, gpui::HighlightStyle)>, links: Vec<(Range, gpui::SharedString)>, style: gpui::StyleRefinement, }, } pub(crate) fn new( content: gpui::SharedString, cx: &mut gpui::Context, ) -> MarkdownText { let mut view = MarkdownText { content, blocks: Vec::new(), }; view.on_create(cx); view } impl Styled for ContentBlock { fn style(&mut self) -> &mut gpui::StyleRefinement { match self { | ContentBlock::Text { style, .. } => style, } } } impl MarkdownText { fn on_create(&mut self, cx: &gpui::Context) { let content = self.content.clone(); let t = cx.background_spawn(async move { let mut parser = tree_sitter::Parser::new(); parser .set_language(tree_sitter_markdown::language()) .expect("tree-sitter-markdown language should load"); parser.parse(content.as_str(), None) }); cx.spawn(async |weak, cx| { if let Some(tree) = t.await { _ = weak.update(cx, |this, cx| { let theme = app::current_theme(cx); this.render_tree(&tree, &theme); cx.notify(); }); }; }) .detach(); } fn on_open_link(&self, _link: &str, _cx: &gpui::Context) {} fn render_tree(&mut self, tree: &tree_sitter::Tree, theme: &theme::Theme) { let mut cursor = tree.walk(); cursor.goto_first_child(); fn block_for_node( cursor: &mut tree_sitter::TreeCursor, content: &str, // byte_offset is the number of bytes to offset the content start byte by byte_offset: usize, theme: &theme::Theme, ) -> ContentBlock { let node_start_byte = cursor.node().start_byte(); let mut highlights: Vec<(Range, gpui::HighlightStyle)> = Vec::new(); let mut links: Vec<(Range, gpui::SharedString)> = Vec::new(); cursor.goto_first_child(); loop { let node = cursor.node(); macro_rules! node_range { () => { (node.start_byte() - node_start_byte - byte_offset) ..(node.end_byte() - node_start_byte - byte_offset) }; } match node.kind_id() { | MARKDOWN_KIND_ID_EMPHASIS => { highlights.push(( node_range!(), gpui::HighlightStyle { font_style: Some(gpui::FontStyle::Italic), ..Default::default() }, )); } | MARKDOWN_KIND_ID_STRONG_EMPHASIS => highlights.push(( node_range!(), gpui::HighlightStyle { font_weight: Some(gpui::FontWeight::BOLD), ..Default::default() }, )), | MARKDOWN_KIND_ID_LINK => { if cursor.goto_first_child() { highlights.push(( node_range!(), gpui::HighlightStyle { color: Some(theme.colors.accent.into()), underline: Some(gpui::UnderlineStyle { color: Some(theme.colors.accent.into()), thickness: px(1.), wavy: false, }), ..Default::default() }, )); if cursor.goto_next_sibling() && let Ok(src) = cursor.node().utf8_text(content.as_bytes()) { links .push((node_range!(), gpui::SharedString::from(String::from(src)))); } else { // the link src is invalid, use an empty string as a fallback // link on click handler will ignore empty string links.push((node_range!(), "".into())) } } } | _ => { // extend here to support more markdown node stylings } }; if !cursor.goto_next_sibling() { break; } } cursor.goto_parent(); ContentBlock::Text { decoration: None, text: gpui::SharedString::new( &content[(node_start_byte + byte_offset)..cursor.node().end_byte()], ), highlights: highlights, links: links, style: gpui::StyleRefinement::default(), } } loop { let current_node = cursor.node(); fn render_fallback_content( cursor: &tree_sitter::TreeCursor, content: &str, blocks: &mut Vec, ) { blocks.push(ContentBlock::Text { decoration: None, text: gpui::SharedString::new(&content[cursor.node().byte_range()]), highlights: Vec::new(), links: Vec::new(), style: gpui::StyleRefinement::default(), }); } fn render_list_node( cursor: &mut tree_sitter::TreeCursor, content: &str, blocks: &mut Vec, theme: &theme::Theme, indentation: usize, ) -> bool { // expected tree shape for node pointed to by cursor: // tight_list // list_item // list_marker // paragraph // tight_list <-- recursive point // go to list_item node if !cursor.goto_first_child() { render_fallback_content(&cursor, content, blocks); return false; } let mut list_index = 0; loop { if cursor.node().kind_id() != MARKDOWN_KIND_ID_LIST_ITEM_TIGHT // if is list_item node, dive into list_marker node || !cursor.goto_first_child() { // encountered non lists item node under tight list node // dont know what to do, so skipping this node if !cursor.goto_next_sibling() { return false; } continue; } let marker_node = cursor.node(); let marker_content = &content[marker_node.byte_range()]; match marker_content { // unordered list item | "-" | "+" | "*" => { // go to paragraph sibling node let block = if cursor.goto_next_sibling() { let mut b = block_for_node(cursor, content, 0, theme); match b { | ContentBlock::Text { ref mut decoration, .. } => *decoration = Some("•"), } b } else { ContentBlock::Text { decoration: Some("•"), text: gpui::SharedString::default(), highlights: Vec::new(), links: Vec::new(), style: gpui::StyleRefinement::default(), } } .text_sm() .text_color(theme.colors.text) .p(rems(indentation as f32)); blocks.push(block); // if there is a nested tight_light after paragraph // render it recursively if cursor.goto_next_sibling() && cursor.node().kind_id() == MARKDOWN_KIND_ID_TIGHT_LIST { render_list_node(cursor, content, blocks, theme, indentation + 1); } } | _ => { render_fallback_content(&cursor, content, blocks); return false; } } // go back to list_item node cursor.goto_parent(); if !cursor.goto_next_sibling() { // no more list_item in tight_list node // go back up to tight_list node cursor.goto_parent(); return true; } } } match current_node.kind_id() { | MARKDOWN_KIND_ID_ATX_HEADING => { if !cursor.goto_first_child() { render_fallback_content(&cursor, &self.content, &mut self.blocks); continue; } let marker_node_kind = cursor.node().kind_id(); let block = if cursor.goto_next_sibling() && cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT { // because HEADING_CONTENT node includes the space after the heading marker // offset by 1 to exclude the space block_for_node(&mut cursor, &self.content, 1, theme) } else { ContentBlock::Text { decoration: None, text: gpui::SharedString::new(&self.content[current_node.byte_range()]), highlights: Vec::new(), links: Vec::new(), style: gpui::StyleRefinement::default(), } }; let block = match marker_node_kind { | MARKDOWN_KIND_ID_ATX_H1_MARKER => block .text_size(rems(2.25)) .font_weight(gpui::FontWeight::EXTRA_BOLD) .mb_8(), | MARKDOWN_KIND_ID_ATX_H2_MARKER => block .text_2xl() .font_weight(gpui::FontWeight::BOLD) .mt_12() .mb_6(), | MARKDOWN_KIND_ID_ATX_H3_MARKER => block .text_xl() .font_weight(gpui::FontWeight::SEMIBOLD) .mt_8() .mb_3(), | MARKDOWN_KIND_ID_ATX_H4_MARKER => block .text_base() .font_weight(gpui::FontWeight::SEMIBOLD) .mt_6() .mb_2(), | _ => block, } .text_color(theme.colors.text); cursor.goto_parent(); self.blocks.push(block); } | MARKDOWN_KIND_ID_PARAGRAPH => { let block = block_for_node(&mut cursor, &self.content, 0, theme) .text_color(theme.colors.text) .text_sm(); self.blocks.push(block); } | MARKDOWN_KIND_ID_TIGHT_LIST => { let is_rendered = render_list_node(&mut cursor, &self.content, &mut self.blocks, theme, 0); if !is_rendered { continue; } } | _ => { println!( "[WARN] formatting not implemenetd for node type {:?}", current_node.kind() ); let block = block_for_node(&mut cursor, &self.content, 0, theme) .text_color(theme.colors.text) .text_sm(); self.blocks.push(block); } } if !cursor.goto_next_sibling() { break; } } } } impl gpui::Render for MarkdownText { fn render( &mut self, _window: &mut gpui::Window, cx: &mut gpui::prelude::Context, ) -> impl gpui::prelude::IntoElement { let theme = app::current_theme(cx); let children = self.blocks.iter().enumerate().map(|(i, block)| { match block { | ContentBlock::Text { decoration, text, highlights, links, style, } => { let styled_text = gpui::StyledText::new(text.clone()).with_highlights(highlights.clone()); let div = decoration .map(|d| div().flex().flex_row().gap_2().items_start().child(d)) .unwrap_or_else(|| div()); let mut div = if links.is_empty() { // if no link in block, interactive text is not needed div.child(styled_text) } else { // if link in block, interactive text is needed // to handle link clicks let (link_ranges, srcs): (Vec<_>, Vec<_>) = links.iter().cloned().unzip(); let weak = cx.entity(); let t = gpui::InteractiveText::new(i, styled_text).on_click( link_ranges, move |i, _, cx| { if let Some(src) = srcs.get(i) { weak.update(cx, |this, cx| { this.on_open_link(src, cx); cx.notify(); }) } }, ); div.child(t) }; div.style().refine(&style); div } } }); div().flex().flex_col().children(children) } }