Skip to main content

gpui_base/text/
markdown_ext.rs

1use std::{
2    any::Any,
3    collections::HashMap,
4    fmt,
5    ops::Range,
6    sync::{
7        Arc,
8        atomic::{AtomicU64, Ordering},
9    },
10};
11
12use gpui::{AnyElement, App, IntoElement, SharedString, Window};
13use markdown::{ParseOptions, mdast};
14
15use super::{InlineElement, InlineRenderContext};
16use crate::text::node::Span;
17
18static MARKDOWN_EXTENSIONS_REVISION: AtomicU64 = AtomicU64::new(1);
19
20/// Re-export of the Markdown AST types used by custom parsers.
21pub use markdown::mdast as markdown_ast;
22
23/// Type for a custom Markdown block parser.
24///
25/// Parsers run during Markdown AST conversion, often on a background task. They
26/// must not depend on [`Window`] or [`App`]; return parsed, reusable data in a
27/// [`MarkdownNode`] and render it later with a block renderer.
28pub type MarkdownBlockParserFn =
29    dyn for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode> + Send + Sync;
30
31/// Type for a custom Markdown block renderer.
32pub type MarkdownBlockRenderFn =
33    dyn Fn(&MarkdownNode, &mut Window, &mut App) -> AnyElement + Send + Sync;
34
35/// Parser for a single inline AST node; follows the block parser contract.
36type MarkdownInlineParserFn = MarkdownBlockParserFn;
37
38/// Produces a native GPUI inline element, or `None` for atomic text fallback.
39/// Read prepared resources here; start asynchronous work outside rendering.
40type MarkdownInlineRenderFn = dyn Fn(&MarkdownNode, &InlineRenderContext, &mut Window, &mut App) -> Option<InlineElement>
41    + Send
42    + Sync;
43
44/// A reusable Markdown extension that parses and renders one custom node.
45pub trait MarkdownPlugin: Send + Sync + 'static {
46    /// Whether this plugin produces block-level nodes.
47    ///
48    /// Plugins are inline by default. Block plugins should return `true`.
49    fn is_block(&self) -> bool {
50        false
51    }
52
53    /// Stable name for nodes produced by this plugin.
54    fn name(&self) -> &str;
55
56    /// Convert an mdast node into a custom Markdown node.
57    fn parse(&self, node: &mdast::Node, cx: &MarkdownParseContext<'_>) -> Option<MarkdownNode>;
58
59    /// Render a custom Markdown node produced by this plugin.
60    fn render(&self, node: &MarkdownNode, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
61        node.as_text().to_string()
62    }
63
64    /// Render an inline node using the existing `render` implementation.
65    /// Override this only when inherited layout context or an explicit baseline is needed.
66    fn render_inline(
67        &self,
68        node: &MarkdownNode,
69        _context: &InlineRenderContext,
70        window: &mut Window,
71        cx: &mut App,
72    ) -> Option<InlineElement> {
73        Some(InlineElement::new(self.render(node, window, cx)))
74    }
75}
76
77/// Context passed to custom Markdown parsers.
78pub struct MarkdownParseContext<'a> {
79    source: &'a str,
80    offset: usize,
81}
82
83impl<'a> MarkdownParseContext<'a> {
84    pub(crate) fn new(source: &'a str, offset: usize) -> Self {
85        Self { source, offset }
86    }
87
88    /// Source text for the Markdown fragment currently being parsed.
89    pub fn source(&self) -> &'a str {
90        self.source
91    }
92
93    /// Byte offset of `source` in the full document when parsing an appended
94    /// fragment.
95    pub fn offset(&self) -> usize {
96        self.offset
97    }
98
99    /// Source slice for a specific mdast node.
100    pub fn node_source(&self, node: &mdast::Node) -> Option<&'a str> {
101        let position = node.position()?;
102        self.source.get(position.start.offset..position.end.offset)
103    }
104}
105
106/// A custom Markdown node produced by [`MarkdownExtensions`].
107#[derive(Clone)]
108pub struct MarkdownNode {
109    name: SharedString,
110    text: SharedString,
111    markdown: SharedString,
112    accessibility_label: Option<SharedString>,
113    data: Arc<dyn Any + Send + Sync>,
114    pub(crate) span: Option<Span>,
115}
116
117impl MarkdownNode {
118    /// Create a custom Markdown node with a stable name and typed data.
119    pub fn new<T>(name: impl Into<SharedString>, data: T) -> Self
120    where
121        T: Any + Send + Sync + 'static,
122    {
123        Self {
124            name: name.into(),
125            text: SharedString::default(),
126            markdown: SharedString::default(),
127            accessibility_label: None,
128            data: Arc::new(data),
129            span: None,
130        }
131    }
132
133    /// Stable name for this custom node.
134    pub fn name(&self) -> &str {
135        &self.name
136    }
137
138    /// Text representation of this custom node.
139    pub fn as_text(&self) -> &str {
140        &self.text
141    }
142
143    /// Markdown representation of this custom node.
144    pub fn as_markdown(&self) -> &str {
145        &self.markdown
146    }
147
148    /// Full-document UTF-8 source byte range, including syntax delimiters.
149    pub fn source_range(&self) -> Option<Range<usize>> {
150        self.span.map(|span| span.start..span.end)
151    }
152
153    /// Name exposed to accessibility clients, defaulting to the plain text.
154    pub fn accessibility_name(&self) -> &str {
155        self.accessibility_label.as_deref().unwrap_or(&self.text)
156    }
157
158    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
159        let label = label.into();
160        self.accessibility_label = (!label.is_empty()).then_some(label);
161        self
162    }
163
164    /// Plain text, sharing this node's buffer.
165    ///
166    /// The inline flow rebuilds its items every frame and holds text by value,
167    /// so going through [`Self::as_text`] would allocate a `String` per object
168    /// per frame.
169    pub(crate) fn shared_text(&self) -> SharedString {
170        self.text.clone()
171    }
172
173    /// Accessible name, sharing this node's buffer. Defaults to the plain text.
174    pub(crate) fn shared_accessibility_name(&self) -> SharedString {
175        self.accessibility_label
176            .clone()
177            .unwrap_or_else(|| self.text.clone())
178    }
179
180    pub(crate) fn with_inline_source(mut self, source: &str) -> Self {
181        if self.text.is_empty() {
182            self.text = source.to_string().into();
183        }
184        if self.markdown.is_empty() {
185            self.markdown = source.to_string().into();
186        }
187        self
188    }
189
190    /// Set the text representation of this custom node.
191    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
192        self.text = text.into();
193        self
194    }
195
196    /// Set the Markdown representation of this custom node.
197    pub fn markdown(mut self, markdown: impl Into<SharedString>) -> Self {
198        self.markdown = markdown.into();
199        self
200    }
201
202    /// Read typed data.
203    pub fn data<T>(&self) -> Option<&T>
204    where
205        T: Any + Send + Sync + 'static,
206    {
207        self.data.downcast_ref()
208    }
209
210    pub(crate) fn set_span(&mut self, span: Option<Span>) {
211        self.span = span;
212    }
213
214    pub(crate) fn to_markdown(&self) -> String {
215        if self.markdown.is_empty() {
216            self.text.to_string()
217        } else {
218            self.markdown.to_string()
219        }
220    }
221}
222
223impl fmt::Debug for MarkdownNode {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        f.debug_struct("MarkdownNode")
226            .field("name", &self.name)
227            .field("text", &self.text)
228            .field("markdown", &self.markdown)
229            .field("span", &self.span)
230            .finish_non_exhaustive()
231    }
232}
233
234impl PartialEq for MarkdownNode {
235    fn eq(&self, other: &Self) -> bool {
236        self.name == other.name
237            && self.text == other.text
238            && self.markdown == other.markdown
239            && self.accessibility_label == other.accessibility_label
240            && self.span == other.span
241    }
242}
243
244/// Registry for custom Markdown parsing and rendering.
245#[derive(Clone, Default)]
246pub struct MarkdownExtensions {
247    enable_mdx: bool,
248    enable_frontmatter: bool,
249    block_parsers: Vec<Arc<MarkdownBlockParserFn>>,
250    block_renderers: HashMap<SharedString, Arc<MarkdownBlockRenderFn>>,
251    inline_parsers: Vec<Arc<MarkdownInlineParserFn>>,
252    inline_renderers: HashMap<SharedString, Arc<MarkdownInlineRenderFn>>,
253    revision: u64,
254    parser_revision: u64,
255}
256
257impl MarkdownExtensions {
258    /// Change this revision when parser captures or plugin configuration change.
259    /// Reusing it allows equivalent registrations rebuilt during rendering to
260    /// retain the parsed document. Renderer-only changes do not need a new value.
261    pub fn parser_revision(mut self, revision: u64) -> Self {
262        self.parser_revision = revision;
263        self.bump_revision();
264        self
265    }
266
267    /// Enable YAML frontmatter parsing.
268    ///
269    /// Frontmatter is disabled by default because it is not part of CommonMark
270    /// or GFM. Register a block parser or [`MarkdownPlugin`] to render the
271    /// resulting [`mdast::Node::Yaml`] node.
272    pub fn frontmatter(mut self) -> Self {
273        self.enable_frontmatter = true;
274        self.bump_revision();
275        self
276    }
277
278    /// Enable MDX JSX/expression constructs.
279    ///
280    /// This disables raw HTML constructs because `markdown-rs` gives HTML
281    /// priority over MDX when both are enabled.
282    pub fn mdx(mut self) -> Self {
283        self.enable_mdx = true;
284        self.bump_revision();
285        self
286    }
287
288    /// Register a parser for block-level Markdown AST nodes.
289    pub fn block_parser<F>(mut self, parser: F) -> Self
290    where
291        F: for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
292            + Send
293            + Sync
294            + 'static,
295    {
296        self.push_block_parser(parser);
297        self
298    }
299
300    /// Register a renderer for a custom block node name.
301    pub fn block_renderer<F, E>(mut self, name: impl Into<SharedString>, renderer: F) -> Self
302    where
303        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
304        E: IntoElement,
305    {
306        self.push_block_renderer(name, renderer);
307        self
308    }
309
310    /// Apply a reusable Markdown plugin.
311    pub fn plugin<P>(self, plugin: P) -> Self
312    where
313        P: MarkdownPlugin,
314    {
315        let plugin = Arc::new(plugin);
316        let name = SharedString::from(plugin.name().to_string());
317        let parser = plugin.clone();
318        let renderer = plugin;
319
320        if parser.is_block() {
321            let mut extensions = self.block_parser(move |node, cx| parser.parse(node, cx));
322            extensions.push_block_renderer(name, move |node, window, cx| {
323                renderer.render(node, window, cx).into_any_element()
324            });
325            extensions
326        } else {
327            let mut extensions = self;
328            extensions
329                .inline_parsers
330                .push(Arc::new(move |node, cx| parser.parse(node, cx)));
331            extensions.inline_renderers.insert(
332                name,
333                Arc::new(move |node, context, window, cx| {
334                    renderer.render_inline(node, context, window, cx)
335                }),
336            );
337            extensions.bump_revision();
338            extensions
339        }
340    }
341
342    pub(crate) fn revision(&self) -> u64 {
343        self.revision
344    }
345
346    /// Whether replacing these extension handles can change the parsed tree.
347    ///
348    /// Render methods commonly rebuild equivalent plugin closures every frame.
349    /// Their globally unique revisions differ, but the parser shape remains
350    /// stable; render handles may be refreshed without reparsing the document.
351    pub(crate) fn has_same_parser_configuration(&self, other: &Self) -> bool {
352        self.parser_revision == other.parser_revision
353            && self.enable_mdx == other.enable_mdx
354            && self.enable_frontmatter == other.enable_frontmatter
355            && self.block_parsers.len() == other.block_parsers.len()
356            && self.block_renderers.len() == other.block_renderers.len()
357            && self.inline_parsers.len() == other.inline_parsers.len()
358            && self.inline_renderers.len() == other.inline_renderers.len()
359            && self
360                .inline_renderers
361                .keys()
362                .all(|name| other.inline_renderers.contains_key(name))
363            && self
364                .block_renderers
365                .keys()
366                .all(|name| other.block_renderers.contains_key(name))
367    }
368
369    pub(crate) fn push_block_parser<F>(&mut self, parser: F)
370    where
371        F: for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
372            + Send
373            + Sync
374            + 'static,
375    {
376        self.block_parsers.push(Arc::new(parser));
377        self.bump_revision();
378    }
379
380    pub(crate) fn push_block_renderer<F, E>(&mut self, name: impl Into<SharedString>, renderer: F)
381    where
382        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
383        E: IntoElement,
384    {
385        self.block_renderers.insert(
386            name.into(),
387            Arc::new(move |node, window, cx| renderer(node, window, cx).into_any_element()),
388        );
389        self.bump_revision();
390    }
391
392    pub(crate) fn parse_options(&self) -> ParseOptions {
393        let mut options = ParseOptions::gfm();
394        options.constructs.frontmatter = self.enable_frontmatter;
395        options.constructs.math_text = true;
396        // Both fences or neither: with only `math_text` on, the inline
397        // construct swallows a `$$` block, so a block plugin matching
398        // `Node::Math` never fires and the formula renders inline.
399        options.constructs.math_flow = true;
400        if self.enable_mdx {
401            options.constructs.html_flow = false;
402            options.constructs.html_text = false;
403            options.constructs.mdx_expression_flow = true;
404            options.constructs.mdx_expression_text = true;
405            options.constructs.mdx_jsx_flow = true;
406            options.constructs.mdx_jsx_text = true;
407        }
408        options
409    }
410
411    pub(crate) fn parse_block(
412        &self,
413        node: &mdast::Node,
414        cx: &MarkdownParseContext<'_>,
415    ) -> Option<MarkdownNode> {
416        for parser in &self.block_parsers {
417            if let Some(node) = parser(node, cx) {
418                return Some(node);
419            }
420        }
421        None
422    }
423
424    pub(crate) fn parse_inline(
425        &self,
426        node: &mdast::Node,
427        cx: &MarkdownParseContext<'_>,
428    ) -> Option<MarkdownNode> {
429        self.inline_parsers
430            .iter()
431            .find_map(|parser| parser(node, cx))
432    }
433
434    pub(crate) fn render_inline(
435        &self,
436        node: &MarkdownNode,
437        context: &InlineRenderContext,
438        window: &mut Window,
439        cx: &mut App,
440    ) -> Option<InlineElement> {
441        self.inline_renderers
442            .get(node.name())
443            .and_then(|render| render(node, context, window, cx))
444    }
445
446    pub(crate) fn render_block(
447        &self,
448        node: &MarkdownNode,
449        window: &mut Window,
450        cx: &mut App,
451    ) -> Option<AnyElement> {
452        self.block_renderers
453            .get(node.name())
454            .map(|render| render(node, window, cx))
455    }
456
457    fn bump_revision(&mut self) {
458        self.revision = MARKDOWN_EXTENSIONS_REVISION.fetch_add(1, Ordering::Relaxed);
459    }
460}
461
462/// Test fixture that exercises the same plugin registration path as applications.
463#[cfg(test)]
464pub(super) struct TestInlinePlugin {
465    name: &'static str,
466    parser: Option<Arc<MarkdownInlineParserFn>>,
467    renderer: Option<Arc<MarkdownInlineRenderFn>>,
468}
469
470#[cfg(test)]
471impl TestInlinePlugin {
472    pub(super) fn new(name: &'static str) -> Self {
473        Self {
474            name,
475            parser: None,
476            renderer: None,
477        }
478    }
479
480    pub(super) fn parse_with<F>(mut self, parser: F) -> Self
481    where
482        F: for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
483            + Send
484            + Sync
485            + 'static,
486    {
487        self.parser = Some(Arc::new(parser));
488        self
489    }
490
491    pub(super) fn render_with<F>(mut self, renderer: F) -> Self
492    where
493        F: Fn(&MarkdownNode, &InlineRenderContext, &mut Window, &mut App) -> Option<InlineElement>
494            + Send
495            + Sync
496            + 'static,
497    {
498        self.renderer = Some(Arc::new(renderer));
499        self
500    }
501}
502
503#[cfg(test)]
504impl MarkdownPlugin for TestInlinePlugin {
505    fn name(&self) -> &str {
506        self.name
507    }
508
509    fn parse(&self, node: &mdast::Node, cx: &MarkdownParseContext<'_>) -> Option<MarkdownNode> {
510        self.parser.as_ref().and_then(|parse| parse(node, cx))
511    }
512
513    fn render_inline(
514        &self,
515        node: &MarkdownNode,
516        context: &InlineRenderContext,
517        window: &mut Window,
518        cx: &mut App,
519    ) -> Option<InlineElement> {
520        self.renderer
521            .as_ref()
522            .and_then(|render| render(node, context, window, cx))
523    }
524}