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, Div, 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/// Builds the container of a [block surface](MarkdownExtensions::block_surface).
36pub type BlockSurfaceRenderFn = dyn Fn(&mut Window, &mut App) -> Div + Send + Sync;
37
38/// Parser for a single inline AST node; follows the block parser contract.
39type MarkdownInlineParserFn = MarkdownBlockParserFn;
40
41/// Produces a native GPUI inline element, or `None` for atomic text fallback.
42/// Read prepared resources here; start asynchronous work outside rendering.
43type MarkdownInlineRenderFn = dyn Fn(&MarkdownNode, &InlineRenderContext, &mut Window, &mut App) -> Option<InlineElement>
44    + Send
45    + Sync;
46
47/// A reusable Markdown extension that parses and renders one custom node.
48pub trait MarkdownPlugin: Send + Sync + 'static {
49    /// Whether this plugin produces block-level nodes.
50    ///
51    /// Plugins are inline by default. Block plugins should return `true`.
52    fn is_block(&self) -> bool {
53        false
54    }
55
56    /// Stable name for nodes produced by this plugin.
57    fn name(&self) -> &str;
58
59    /// Convert an mdast node into a custom Markdown node.
60    fn parse(&self, node: &mdast::Node, cx: &MarkdownParseContext<'_>) -> Option<MarkdownNode>;
61
62    /// Render a custom Markdown node produced by this plugin.
63    fn render(&self, node: &MarkdownNode, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
64        node.as_text().to_string()
65    }
66
67    /// Render an inline node using the existing `render` implementation.
68    /// Override this only when inherited layout context or an explicit baseline is needed.
69    fn render_inline(
70        &self,
71        node: &MarkdownNode,
72        _context: &InlineRenderContext,
73        window: &mut Window,
74        cx: &mut App,
75    ) -> Option<InlineElement> {
76        Some(InlineElement::new(self.render(node, window, cx)))
77    }
78}
79
80/// Context passed to custom Markdown parsers.
81pub struct MarkdownParseContext<'a> {
82    source: &'a str,
83    offset: usize,
84}
85
86impl<'a> MarkdownParseContext<'a> {
87    pub(crate) fn new(source: &'a str, offset: usize) -> Self {
88        Self { source, offset }
89    }
90
91    /// Source text for the Markdown fragment currently being parsed.
92    pub fn source(&self) -> &'a str {
93        self.source
94    }
95
96    /// Byte offset of `source` in the full document when parsing an appended
97    /// fragment.
98    pub fn offset(&self) -> usize {
99        self.offset
100    }
101
102    /// Source slice for a specific mdast node.
103    pub fn node_source(&self, node: &mdast::Node) -> Option<&'a str> {
104        let position = node.position()?;
105        self.source.get(position.start.offset..position.end.offset)
106    }
107}
108
109/// A custom Markdown node produced by [`MarkdownExtensions`].
110#[derive(Clone)]
111pub struct MarkdownNode {
112    name: SharedString,
113    text: SharedString,
114    markdown: SharedString,
115    accessibility_label: Option<SharedString>,
116    data: Arc<dyn Any + Send + Sync>,
117    pub(crate) span: Option<Span>,
118}
119
120impl MarkdownNode {
121    /// Create a custom Markdown node with a stable name and typed data.
122    pub fn new<T>(name: impl Into<SharedString>, data: T) -> Self
123    where
124        T: Any + Send + Sync + 'static,
125    {
126        Self {
127            name: name.into(),
128            text: SharedString::default(),
129            markdown: SharedString::default(),
130            accessibility_label: None,
131            data: Arc::new(data),
132            span: None,
133        }
134    }
135
136    /// Stable name for this custom node.
137    pub fn name(&self) -> &str {
138        &self.name
139    }
140
141    /// Text representation of this custom node.
142    pub fn as_text(&self) -> &str {
143        &self.text
144    }
145
146    /// Markdown representation of this custom node.
147    pub fn as_markdown(&self) -> &str {
148        &self.markdown
149    }
150
151    /// Full-document UTF-8 source byte range, including syntax delimiters.
152    pub fn source_range(&self) -> Option<Range<usize>> {
153        self.span.map(|span| span.start..span.end)
154    }
155
156    /// Name exposed to accessibility clients, defaulting to the plain text.
157    pub fn accessibility_name(&self) -> &str {
158        self.accessibility_label.as_deref().unwrap_or(&self.text)
159    }
160
161    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
162        let label = label.into();
163        self.accessibility_label = (!label.is_empty()).then_some(label);
164        self
165    }
166
167    /// Plain text, sharing this node's buffer.
168    ///
169    /// The inline flow rebuilds its items every frame and holds text by value,
170    /// so going through [`Self::as_text`] would allocate a `String` per object
171    /// per frame.
172    pub(crate) fn shared_text(&self) -> SharedString {
173        self.text.clone()
174    }
175
176    /// Accessible name, sharing this node's buffer. Defaults to the plain text.
177    pub(crate) fn shared_accessibility_name(&self) -> SharedString {
178        self.accessibility_label
179            .clone()
180            .unwrap_or_else(|| self.text.clone())
181    }
182
183    pub(crate) fn with_inline_source(mut self, source: &str) -> Self {
184        if self.text.is_empty() {
185            self.text = source.to_string().into();
186        }
187        if self.markdown.is_empty() {
188            self.markdown = source.to_string().into();
189        }
190        self
191    }
192
193    /// Set the text representation of this custom node.
194    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
195        self.text = text.into();
196        self
197    }
198
199    /// Set the Markdown representation of this custom node.
200    pub fn markdown(mut self, markdown: impl Into<SharedString>) -> Self {
201        self.markdown = markdown.into();
202        self
203    }
204
205    /// Read typed data.
206    pub fn data<T>(&self) -> Option<&T>
207    where
208        T: Any + Send + Sync + 'static,
209    {
210        self.data.downcast_ref()
211    }
212
213    pub(crate) fn set_span(&mut self, span: Option<Span>) {
214        self.span = span;
215    }
216
217    pub(crate) fn to_markdown(&self) -> String {
218        if self.markdown.is_empty() {
219            self.text.to_string()
220        } else {
221            self.markdown.to_string()
222        }
223    }
224}
225
226impl fmt::Debug for MarkdownNode {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        f.debug_struct("MarkdownNode")
229            .field("name", &self.name)
230            .field("text", &self.text)
231            .field("markdown", &self.markdown)
232            .field("span", &self.span)
233            .finish_non_exhaustive()
234    }
235}
236
237impl PartialEq for MarkdownNode {
238    fn eq(&self, other: &Self) -> bool {
239        self.name == other.name
240            && self.text == other.text
241            && self.markdown == other.markdown
242            && self.accessibility_label == other.accessibility_label
243            && self.span == other.span
244    }
245}
246
247/// Registry for custom Markdown parsing and rendering.
248#[derive(Clone, Default)]
249pub struct MarkdownExtensions {
250    enable_mdx: bool,
251    enable_frontmatter: bool,
252    block_parsers: Vec<Arc<MarkdownBlockParserFn>>,
253    block_renderers: HashMap<SharedString, Arc<MarkdownBlockRenderFn>>,
254    inline_parsers: Vec<Arc<MarkdownInlineParserFn>>,
255    inline_renderers: HashMap<SharedString, Arc<MarkdownInlineRenderFn>>,
256    block_surfaces: HashMap<SharedString, Arc<BlockSurfaceRenderFn>>,
257    revision: u64,
258    parser_revision: u64,
259}
260
261impl MarkdownExtensions {
262    /// Change this revision when parser captures or plugin configuration change.
263    /// Reusing it allows equivalent registrations rebuilt during rendering to
264    /// retain the parsed document. Renderer-only changes do not need a new value.
265    pub fn parser_revision(mut self, revision: u64) -> Self {
266        self.parser_revision = revision;
267        self.bump_revision();
268        self
269    }
270
271    /// Enable YAML frontmatter parsing.
272    ///
273    /// Frontmatter is disabled by default because it is not part of CommonMark
274    /// or GFM. Register a block parser or [`MarkdownPlugin`] to render the
275    /// resulting [`mdast::Node::Yaml`] node.
276    pub fn frontmatter(mut self) -> Self {
277        self.enable_frontmatter = true;
278        self.bump_revision();
279        self
280    }
281
282    /// Enable MDX JSX/expression constructs.
283    ///
284    /// This disables raw HTML constructs because `markdown-rs` gives HTML
285    /// priority over MDX when both are enabled.
286    pub fn mdx(mut self) -> Self {
287        self.enable_mdx = true;
288        self.bump_revision();
289        self
290    }
291
292    /// Register a parser for block-level Markdown AST nodes.
293    pub fn block_parser<F>(mut self, parser: F) -> Self
294    where
295        F: for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
296            + Send
297            + Sync
298            + 'static,
299    {
300        self.push_block_parser(parser);
301        self
302    }
303
304    /// Register a renderer for a custom block node name.
305    pub fn block_renderer<F, E>(mut self, name: impl Into<SharedString>, renderer: F) -> Self
306    where
307        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
308        E: IntoElement,
309    {
310        self.push_block_renderer(name, renderer);
311        self
312    }
313
314    /// Draw a top-level blockquote that opens with the marker `[!name]` inside
315    /// a container.
316    ///
317    /// ```markdown
318    /// > [!summary]
319    /// > Blocks of any kind, as many as you like.
320    /// >
321    /// > - They stay part of the document.
322    /// ```
323    ///
324    /// This is the syntax of GitHub alerts, with names of your own, and other
325    /// renderers show it as a blockquote. Names match ASCII-case-insensitively,
326    /// so `[!NOTE]` opens a surface registered as `note`. The marker is alone on
327    /// the first line, and is not document text; the lines after it are the
328    /// first paragraph.
329    ///
330    /// `render` runs each time the document renders and returns the container
331    /// with its background, shape, padding and text style, and any children to
332    /// show before the blocks, such as an icon and a title. The blocks are
333    /// appended to it and bring the view's paragraph gap, so a `gap` on the
334    /// container adds to that. Read theme colors in `render`, so a theme change
335    /// needs no new parse. What the container shows itself is not document text.
336    /// Unless the container sets a width, it takes the row's.
337    ///
338    /// The blocks remain the document's: selection, range highlights and
339    /// reveals go across and inside the container, and
340    /// [`rendered_text`](super::TextViewState::rendered_text) has them without
341    /// the marker. A select-all copies the source verbatim. A partial source
342    /// copy is quoted under the marker line, as a blockquote's is quoted.
343    ///
344    /// Only a blockquote at the top level of a Markdown view, with a registered
345    /// name, is a surface. A nested one stays a blockquote, and so does one with
346    /// text after the marker on its line, such as Obsidian's `> [!note] Title`:
347    /// the container shows its own title. Surfaces do not nest. The marker is
348    /// markup, so escape text you don't control, as with any other Markdown.
349    ///
350    /// A surface is one row of a scrollable view, so keep it to what fits a few
351    /// screens. While it is the last block of a streaming view, each append
352    /// parses and measures all of it again, so streaming one costs time
353    /// quadratic in its length. Until its marker line is complete, it shows as
354    /// a blockquote.
355    pub fn block_surface<F>(mut self, name: impl Into<SharedString>, render: F) -> Self
356    where
357        F: Fn(&mut Window, &mut App) -> Div + Send + Sync + 'static,
358    {
359        let name = name.into();
360        let name = if name.bytes().any(|byte| byte.is_ascii_uppercase()) {
361            name.to_ascii_lowercase().into()
362        } else {
363            name
364        };
365        self.block_surfaces.insert(name, Arc::new(render));
366        self.bump_revision();
367        self
368    }
369
370    /// Apply a reusable Markdown plugin.
371    pub fn plugin<P>(self, plugin: P) -> Self
372    where
373        P: MarkdownPlugin,
374    {
375        let plugin = Arc::new(plugin);
376        let name = SharedString::from(plugin.name().to_string());
377        let parser = plugin.clone();
378        let renderer = plugin;
379
380        if parser.is_block() {
381            let mut extensions = self.block_parser(move |node, cx| parser.parse(node, cx));
382            extensions.push_block_renderer(name, move |node, window, cx| {
383                renderer.render(node, window, cx).into_any_element()
384            });
385            extensions
386        } else {
387            let mut extensions = self;
388            extensions
389                .inline_parsers
390                .push(Arc::new(move |node, cx| parser.parse(node, cx)));
391            extensions.inline_renderers.insert(
392                name,
393                Arc::new(move |node, context, window, cx| {
394                    renderer.render_inline(node, context, window, cx)
395                }),
396            );
397            extensions.bump_revision();
398            extensions
399        }
400    }
401
402    pub(crate) fn revision(&self) -> u64 {
403        self.revision
404    }
405
406    /// Whether replacing these extension handles can change the parsed tree.
407    ///
408    /// Render methods commonly rebuild equivalent plugin closures every frame.
409    /// Their globally unique revisions differ, but the parser shape remains
410    /// stable; render handles may be refreshed without reparsing the document.
411    pub(crate) fn has_same_parser_configuration(&self, other: &Self) -> bool {
412        self.parser_revision == other.parser_revision
413            && self.enable_mdx == other.enable_mdx
414            && self.enable_frontmatter == other.enable_frontmatter
415            && self.block_parsers.len() == other.block_parsers.len()
416            && self.block_renderers.len() == other.block_renderers.len()
417            && self.inline_parsers.len() == other.inline_parsers.len()
418            && self.inline_renderers.len() == other.inline_renderers.len()
419            && self.block_surfaces.len() == other.block_surfaces.len()
420            && self
421                .block_surfaces
422                .keys()
423                .all(|name| other.block_surfaces.contains_key(name))
424            && self
425                .inline_renderers
426                .keys()
427                .all(|name| other.inline_renderers.contains_key(name))
428            && self
429                .block_renderers
430                .keys()
431                .all(|name| other.block_renderers.contains_key(name))
432    }
433
434    pub(crate) fn push_block_parser<F>(&mut self, parser: F)
435    where
436        F: for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
437            + Send
438            + Sync
439            + 'static,
440    {
441        self.block_parsers.push(Arc::new(parser));
442        self.bump_revision();
443    }
444
445    pub(crate) fn push_block_renderer<F, E>(&mut self, name: impl Into<SharedString>, renderer: F)
446    where
447        F: Fn(&MarkdownNode, &mut Window, &mut App) -> E + Send + Sync + 'static,
448        E: IntoElement,
449    {
450        self.block_renderers.insert(
451            name.into(),
452            Arc::new(move |node, window, cx| renderer(node, window, cx).into_any_element()),
453        );
454        self.bump_revision();
455    }
456
457    pub(crate) fn parse_options(&self) -> ParseOptions {
458        let mut options = ParseOptions::gfm();
459        options.constructs.frontmatter = self.enable_frontmatter;
460        options.constructs.math_text = true;
461        // Both fences or neither: with only `math_text` on, the inline
462        // construct swallows a `$$` block, so a block plugin matching
463        // `Node::Math` never fires and the formula renders inline.
464        options.constructs.math_flow = true;
465        if self.enable_mdx {
466            options.constructs.html_flow = false;
467            options.constructs.html_text = false;
468            options.constructs.mdx_expression_flow = true;
469            options.constructs.mdx_expression_text = true;
470            options.constructs.mdx_jsx_flow = true;
471            options.constructs.mdx_jsx_text = true;
472        }
473        options
474    }
475
476    pub(crate) fn has_block_surfaces(&self) -> bool {
477        !self.block_surfaces.is_empty()
478    }
479
480    /// The registered name that `written` matches, ignoring ASCII case.
481    pub(crate) fn block_surface_name(&self, written: &str) -> Option<SharedString> {
482        let name = if written.bytes().any(|byte| byte.is_ascii_uppercase()) {
483            self.block_surfaces
484                .get_key_value(written.to_ascii_lowercase().as_str())
485        } else {
486            self.block_surfaces.get_key_value(written)
487        };
488        name.map(|(name, _)| name.clone())
489    }
490
491    pub(crate) fn render_block_surface(
492        &self,
493        name: &str,
494        window: &mut Window,
495        cx: &mut App,
496    ) -> Option<Div> {
497        self.block_surfaces
498            .get(name)
499            .map(|render| render(window, cx))
500    }
501
502    pub(crate) fn parse_block(
503        &self,
504        node: &mdast::Node,
505        cx: &MarkdownParseContext<'_>,
506    ) -> Option<MarkdownNode> {
507        for parser in &self.block_parsers {
508            if let Some(node) = parser(node, cx) {
509                return Some(node);
510            }
511        }
512        None
513    }
514
515    pub(crate) fn parse_inline(
516        &self,
517        node: &mdast::Node,
518        cx: &MarkdownParseContext<'_>,
519    ) -> Option<MarkdownNode> {
520        self.inline_parsers
521            .iter()
522            .find_map(|parser| parser(node, cx))
523    }
524
525    pub(crate) fn render_inline(
526        &self,
527        node: &MarkdownNode,
528        context: &InlineRenderContext,
529        window: &mut Window,
530        cx: &mut App,
531    ) -> Option<InlineElement> {
532        self.inline_renderers
533            .get(node.name())
534            .and_then(|render| render(node, context, window, cx))
535    }
536
537    pub(crate) fn render_block(
538        &self,
539        node: &MarkdownNode,
540        window: &mut Window,
541        cx: &mut App,
542    ) -> Option<AnyElement> {
543        self.block_renderers
544            .get(node.name())
545            .map(|render| render(node, window, cx))
546    }
547
548    fn bump_revision(&mut self) {
549        self.revision = MARKDOWN_EXTENSIONS_REVISION.fetch_add(1, Ordering::Relaxed);
550    }
551}
552
553/// Test fixture that exercises the same plugin registration path as applications.
554#[cfg(test)]
555pub(super) struct TestInlinePlugin {
556    name: &'static str,
557    parser: Option<Arc<MarkdownInlineParserFn>>,
558    renderer: Option<Arc<MarkdownInlineRenderFn>>,
559}
560
561#[cfg(test)]
562impl TestInlinePlugin {
563    pub(super) fn new(name: &'static str) -> Self {
564        Self {
565            name,
566            parser: None,
567            renderer: None,
568        }
569    }
570
571    pub(super) fn parse_with<F>(mut self, parser: F) -> Self
572    where
573        F: for<'a> Fn(&mdast::Node, &MarkdownParseContext<'a>) -> Option<MarkdownNode>
574            + Send
575            + Sync
576            + 'static,
577    {
578        self.parser = Some(Arc::new(parser));
579        self
580    }
581
582    pub(super) fn render_with<F>(mut self, renderer: F) -> Self
583    where
584        F: Fn(&MarkdownNode, &InlineRenderContext, &mut Window, &mut App) -> Option<InlineElement>
585            + Send
586            + Sync
587            + 'static,
588    {
589        self.renderer = Some(Arc::new(renderer));
590        self
591    }
592}
593
594#[cfg(test)]
595impl MarkdownPlugin for TestInlinePlugin {
596    fn name(&self) -> &str {
597        self.name
598    }
599
600    fn parse(&self, node: &mdast::Node, cx: &MarkdownParseContext<'_>) -> Option<MarkdownNode> {
601        self.parser.as_ref().and_then(|parse| parse(node, cx))
602    }
603
604    fn render_inline(
605        &self,
606        node: &MarkdownNode,
607        context: &InlineRenderContext,
608        window: &mut Window,
609        cx: &mut App,
610    ) -> Option<InlineElement> {
611        self.renderer
612            .as_ref()
613            .and_then(|render| render(node, context, window, cx))
614    }
615}