Skip to main content

gpui_base/text/
markdown_ext.rs

1use std::{
2    any::Any,
3    collections::HashMap,
4    fmt,
5    sync::{
6        Arc,
7        atomic::{AtomicU64, Ordering},
8    },
9};
10
11use gpui::{AnyElement, App, IntoElement, SharedString, Window};
12use markdown::{ParseOptions, mdast};
13
14use crate::text::node::Span;
15
16static MARKDOWN_EXTENSIONS_REVISION: AtomicU64 = AtomicU64::new(1);
17
18/// Re-export of the Markdown AST types used by custom parsers.
19pub use markdown::mdast as markdown_ast;
20
21/// Type for a custom Markdown block parser.
22///
23/// Parsers run during Markdown AST conversion, often on a background task. They
24/// must not depend on [`Window`] or [`App`]; return parsed, reusable data in a
25/// [`MarkdownNode`] and render it later with a block renderer.
26pub type MarkdownBlockParserFn =
27    dyn for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode> + Send + Sync;
28
29/// Type for a custom Markdown block renderer.
30pub type MarkdownBlockRenderFn =
31    dyn Fn(&MarkdownNode, &mut Window, &mut App) -> AnyElement + Send + Sync;
32
33/// A reusable Markdown extension that parses and renders one custom node.
34pub trait MarkdownPlugin: Send + Sync + 'static {
35    /// Whether this plugin produces block-level nodes.
36    ///
37    /// Plugins are inline by default. TextView does not support inline custom
38    /// Markdown rendering yet, so block plugins should return `true`.
39    fn is_block(&self) -> bool {
40        false
41    }
42
43    /// Stable name for nodes produced by this plugin.
44    fn name(&self) -> &str;
45
46    /// Convert an mdast node into a custom Markdown node.
47    fn parse(&self, node: &mdast::Node, cx: &MarkdownParseContext<'_>) -> Option<MarkdownNode>;
48
49    /// Render a custom Markdown node produced by this plugin.
50    fn render(&self, node: &MarkdownNode, window: &mut Window, cx: &mut App) -> impl IntoElement;
51}
52
53/// Context passed to custom Markdown parsers.
54pub struct MarkdownParseContext<'a> {
55    source: &'a str,
56    offset: usize,
57}
58
59impl<'a> MarkdownParseContext<'a> {
60    pub(crate) fn new(source: &'a str, offset: usize) -> Self {
61        Self { source, offset }
62    }
63
64    /// Source text for the Markdown fragment currently being parsed.
65    pub fn source(&self) -> &'a str {
66        self.source
67    }
68
69    /// Byte offset of `source` in the full document when parsing an appended
70    /// fragment.
71    pub fn offset(&self) -> usize {
72        self.offset
73    }
74
75    /// Source slice for a specific mdast node.
76    pub fn node_source(&self, node: &mdast::Node) -> Option<&'a str> {
77        let position = node.position()?;
78        self.source.get(position.start.offset..position.end.offset)
79    }
80}
81
82/// A custom Markdown node produced by [`MarkdownExtensions`].
83#[derive(Clone)]
84pub struct MarkdownNode {
85    name: SharedString,
86    text: SharedString,
87    markdown: SharedString,
88    data: Arc<dyn Any + Send + Sync>,
89    pub(crate) span: Option<Span>,
90}
91
92impl MarkdownNode {
93    /// Create a custom Markdown node with a stable name and typed data.
94    pub fn new<T>(name: impl Into<SharedString>, data: T) -> Self
95    where
96        T: Any + Send + Sync + 'static,
97    {
98        Self {
99            name: name.into(),
100            text: SharedString::default(),
101            markdown: SharedString::default(),
102            data: Arc::new(data),
103            span: None,
104        }
105    }
106
107    /// Stable name for this custom node.
108    pub fn name(&self) -> &str {
109        &self.name
110    }
111
112    /// Text representation of this custom node.
113    pub fn as_text(&self) -> &str {
114        &self.text
115    }
116
117    /// Markdown representation of this custom node.
118    pub fn as_markdown(&self) -> &str {
119        &self.markdown
120    }
121
122    /// Set the text representation of this custom node.
123    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
124        self.text = text.into();
125        self
126    }
127
128    /// Set the Markdown representation of this custom node.
129    pub fn markdown(mut self, markdown: impl Into<SharedString>) -> Self {
130        self.markdown = markdown.into();
131        self
132    }
133
134    /// Read typed data.
135    pub fn data<T>(&self) -> Option<&T>
136    where
137        T: Any + Send + Sync + 'static,
138    {
139        self.data.downcast_ref()
140    }
141
142    pub(crate) fn set_span(&mut self, span: Option<Span>) {
143        self.span = span;
144    }
145
146    pub(crate) fn to_markdown(&self) -> String {
147        if self.markdown.is_empty() {
148            self.text.to_string()
149        } else {
150            self.markdown.to_string()
151        }
152    }
153}
154
155impl fmt::Debug for MarkdownNode {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        f.debug_struct("MarkdownNode")
158            .field("name", &self.name)
159            .field("text", &self.text)
160            .field("markdown", &self.markdown)
161            .field("span", &self.span)
162            .finish_non_exhaustive()
163    }
164}
165
166impl PartialEq for MarkdownNode {
167    fn eq(&self, other: &Self) -> bool {
168        self.name == other.name
169            && self.text == other.text
170            && self.markdown == other.markdown
171            && self.span == other.span
172    }
173}
174
175/// Registry for custom Markdown parsing and rendering.
176#[derive(Clone, Default)]
177pub struct MarkdownExtensions {
178    enable_mdx: bool,
179    enable_frontmatter: bool,
180    block_parsers: Vec<Arc<MarkdownBlockParserFn>>,
181    block_renderers: HashMap<SharedString, Arc<MarkdownBlockRenderFn>>,
182    revision: u64,
183}
184
185impl MarkdownExtensions {
186    /// Enable YAML frontmatter parsing.
187    ///
188    /// Frontmatter is disabled by default because it is not part of CommonMark
189    /// or GFM. Register a block parser or [`MarkdownPlugin`] to render the
190    /// resulting [`mdast::Node::Yaml`] node.
191    pub fn frontmatter(mut self) -> Self {
192        self.enable_frontmatter = true;
193        self.bump_revision();
194        self
195    }
196
197    /// Enable MDX JSX/expression constructs.
198    ///
199    /// This disables raw HTML constructs because `markdown-rs` gives HTML
200    /// priority over MDX when both are enabled.
201    pub fn mdx(mut self) -> Self {
202        self.enable_mdx = true;
203        self.bump_revision();
204        self
205    }
206
207    /// Register a parser for block-level Markdown AST nodes.
208    pub fn block_parser<F>(mut self, parser: F) -> Self
209    where
210        F: for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
211            + Send
212            + Sync
213            + 'static,
214    {
215        self.push_block_parser(parser);
216        self
217    }
218
219    /// Register a renderer for a custom block node name.
220    pub fn block_renderer<F, E>(mut self, name: impl Into<SharedString>, renderer: F) -> Self
221    where
222        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
223        E: IntoElement,
224    {
225        self.push_block_renderer(name, renderer);
226        self
227    }
228
229    /// Apply a reusable Markdown plugin.
230    pub fn plugin<P>(self, plugin: P) -> Self
231    where
232        P: MarkdownPlugin,
233    {
234        let plugin = Arc::new(plugin);
235        let name = SharedString::from(plugin.name().to_string());
236        let parser = plugin.clone();
237        let renderer = plugin;
238
239        if parser.is_block() {
240            let mut extensions = self.block_parser(move |node, cx| parser.parse(node, cx));
241            extensions.push_block_renderer(name, move |node, window, cx| {
242                renderer.render(node, window, cx).into_any_element()
243            });
244            extensions
245        } else {
246            panic!("inline Markdown plugins are not supported by TextView yet")
247        }
248    }
249
250    pub(crate) fn revision(&self) -> u64 {
251        self.revision
252    }
253
254    /// Whether replacing these extension handles can change the parsed tree.
255    ///
256    /// Render methods commonly rebuild equivalent plugin closures every frame.
257    /// Their globally unique revisions differ, but the parser shape remains
258    /// stable; render handles may be refreshed without reparsing the document.
259    pub(crate) fn has_same_parser_configuration(&self, other: &Self) -> bool {
260        self.enable_mdx == other.enable_mdx
261            && self.enable_frontmatter == other.enable_frontmatter
262            && self.block_parsers.len() == other.block_parsers.len()
263            && self.block_renderers.len() == other.block_renderers.len()
264            && self
265                .block_renderers
266                .keys()
267                .all(|name| other.block_renderers.contains_key(name))
268    }
269
270    pub(crate) fn push_block_parser<F>(&mut self, parser: F)
271    where
272        F: for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
273            + Send
274            + Sync
275            + 'static,
276    {
277        self.block_parsers.push(Arc::new(parser));
278        self.bump_revision();
279    }
280
281    pub(crate) fn push_block_renderer<F, E>(&mut self, name: impl Into<SharedString>, renderer: F)
282    where
283        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
284        E: IntoElement,
285    {
286        self.block_renderers.insert(
287            name.into(),
288            Arc::new(move |node, window, cx| renderer(node, window, cx).into_any_element()),
289        );
290        self.bump_revision();
291    }
292
293    pub(crate) fn parse_options(&self) -> ParseOptions {
294        let mut options = ParseOptions::gfm();
295        options.constructs.frontmatter = self.enable_frontmatter;
296        if self.enable_mdx {
297            options.constructs.html_flow = false;
298            options.constructs.html_text = false;
299            options.constructs.mdx_expression_flow = true;
300            options.constructs.mdx_expression_text = true;
301            options.constructs.mdx_jsx_flow = true;
302            options.constructs.mdx_jsx_text = true;
303        }
304        options
305    }
306
307    pub(crate) fn parse_block(
308        &self,
309        node: &mdast::Node,
310        cx: &MarkdownParseContext<'_>,
311    ) -> Option<MarkdownNode> {
312        for parser in &self.block_parsers {
313            if let Some(node) = parser(node, cx) {
314                return Some(node);
315            }
316        }
317        None
318    }
319
320    pub(crate) fn render_block(
321        &self,
322        node: &MarkdownNode,
323        window: &mut Window,
324        cx: &mut App,
325    ) -> Option<AnyElement> {
326        self.block_renderers
327            .get(node.name())
328            .map(|render| render(node, window, cx))
329    }
330
331    fn bump_revision(&mut self) {
332        self.revision = MARKDOWN_EXTENSIONS_REVISION.fetch_add(1, Ordering::Relaxed);
333    }
334}