Skip to main content

rushdown_diagram/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(not(feature = "std"), no_std)]
3
4extern crate alloc;
5
6use alloc::boxed::Box;
7use alloc::vec::Vec;
8use rushdown::as_extension_data;
9use rushdown::as_extension_data_mut;
10use rushdown::as_kind_data;
11use rushdown::as_type_data;
12use rushdown::as_type_data_mut;
13use rushdown::ast::walk;
14use rushdown::context::BoolValue;
15use rushdown::context::ContextKey;
16use rushdown::context::ContextKeyRegistry;
17use rushdown::parser::AnyAstTransformer;
18use rushdown::parser::AstTransformer;
19use rushdown::parser::ParserOptions;
20use rushdown::renderer::PostRender;
21use rushdown::renderer::Render;
22
23use core::any::TypeId;
24use core::error::Error as CoreError;
25use core::fmt;
26use core::fmt::Write;
27use core::result::Result as CoreResult;
28use std::cell::RefCell;
29use std::io::Write as _;
30use std::process::Command;
31use std::process::Stdio;
32use std::rc::Rc;
33
34use rushdown::{
35    ast::{pp_indent, Arena, KindData, NodeKind, NodeRef, NodeType, PrettyPrint, WalkStatus},
36    matches_kind,
37    parser::{self, Parser, ParserExtension, ParserExtensionFn},
38    renderer::{
39        self,
40        html::{self, Renderer, RendererExtension, RendererExtensionFn},
41        BoxRenderNode, NodeRenderer, NodeRendererRegistry, RenderNode, RendererOptions, TextWrite,
42    },
43    text::{self, Reader},
44    Result,
45};
46
47// AST {{{
48
49/// A struct representing a diagram in the AST.
50#[derive(Debug)]
51pub struct Diagram {
52    diagram_type: DiagramType,
53    value: text::Lines,
54}
55
56/// An enum representing the type of a diagram.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum DiagramType {
59    #[default]
60    Mermaid,
61    PlantUml,
62}
63
64impl Diagram {
65    /// Returns a new [`Diagram`] with the given diagram type.
66    pub fn new(diagram_type: DiagramType) -> Self {
67        Self {
68            diagram_type,
69            value: text::Lines::default(),
70        }
71    }
72
73    /// Returns the type of the diagram.
74    #[inline(always)]
75    pub fn diagram_type(&self) -> DiagramType {
76        self.diagram_type
77    }
78
79    /// Returns the value of the diagram as a slice of lines.
80    #[inline(always)]
81    pub fn value(&self) -> &text::Lines {
82        &self.value
83    }
84
85    /// Sets the value of the diagram.
86    pub fn set_value(&mut self, value: impl Into<text::Lines>) {
87        self.value = value.into();
88    }
89}
90
91impl NodeKind for Diagram {
92    fn typ(&self) -> NodeType {
93        NodeType::LeafBlock
94    }
95
96    fn kind_name(&self) -> &'static str {
97        "Diagram"
98    }
99}
100
101impl PrettyPrint for Diagram {
102    fn pretty_print(&self, w: &mut dyn Write, source: &str, level: usize) -> fmt::Result {
103        writeln!(
104            w,
105            "{}DiagramType: {:?}",
106            pp_indent(level),
107            self.diagram_type()
108        )?;
109        write!(w, "{}Value: ", pp_indent(level))?;
110        writeln!(w, "[ ")?;
111        for line in self.value.iter(source) {
112            write!(w, "{}{}", pp_indent(level + 1), line)?;
113        }
114        writeln!(w)?;
115        writeln!(w, "{}]", pp_indent(level))
116    }
117}
118
119impl From<Diagram> for KindData {
120    fn from(e: Diagram) -> Self {
121        KindData::Extension(Box::new(e))
122    }
123}
124
125// }}} AST
126
127// Parser {{{
128
129/// Options for the diagram parser.
130#[derive(Debug, Clone, Default)]
131pub struct DiagramParserOptions {
132    pub mermaid: MermaidParserOptions,
133    pub plantuml: PlantUmlParserOptions,
134}
135
136/// Options for the Mermaid diagram parser.
137#[derive(Debug, Clone)]
138pub struct MermaidParserOptions {
139    pub enabled: bool,
140}
141
142impl ParserOptions for DiagramParserOptions {}
143
144impl Default for MermaidParserOptions {
145    fn default() -> Self {
146        Self { enabled: true }
147    }
148}
149
150/// Options for the PlantUML diagram parser.
151#[derive(Debug, Clone)]
152pub struct PlantUmlParserOptions {
153    pub enabled: bool,
154}
155
156impl Default for PlantUmlParserOptions {
157    fn default() -> Self {
158        Self { enabled: true }
159    }
160}
161
162#[derive(Debug)]
163struct DiagramAstTransformer {
164    options: DiagramParserOptions,
165}
166
167impl DiagramAstTransformer {
168    pub fn with_options(options: DiagramParserOptions) -> Self {
169        Self { options }
170    }
171}
172
173impl AstTransformer for DiagramAstTransformer {
174    fn transform(
175        &self,
176        arena: &mut Arena,
177        doc_ref: NodeRef,
178        reader: &mut text::BasicReader,
179        _ctx: &mut parser::Context,
180    ) {
181        let mut target_codes: Option<Vec<NodeRef>> = None;
182        walk(arena, doc_ref, &mut |arena: &Arena,
183                                   node_ref: NodeRef,
184                                   entering: bool|
185         -> Result<WalkStatus> {
186            if entering && matches_kind!(arena[node_ref], CodeBlock) {
187                let code_block = as_kind_data!(arena[node_ref], CodeBlock);
188                if let Some(lang) = code_block.language_str(reader.source()) {
189                    if lang == "mermaid" || lang == "plantuml" {
190                        if target_codes.is_none() {
191                            target_codes = Some(Vec::new());
192                        }
193                        target_codes.as_mut().unwrap().push(node_ref);
194                    }
195                }
196            }
197            Ok(WalkStatus::Continue)
198        })
199        .ok();
200        if let Some(target_codes) = target_codes {
201            for code_ref in target_codes {
202                let code_block = as_kind_data!(arena[code_ref], CodeBlock);
203                let lines = code_block.value().clone();
204                let pos = arena[code_ref].pos();
205                let diagram_type = match code_block.language_str(reader.source()) {
206                    Some("mermaid") => {
207                        if self.options.mermaid.enabled {
208                            DiagramType::Mermaid
209                        } else {
210                            continue;
211                        }
212                    }
213                    Some("plantuml") => {
214                        if self.options.plantuml.enabled {
215                            DiagramType::PlantUml
216                        } else {
217                            continue;
218                        }
219                    }
220                    _ => continue,
221                };
222                let diagram = arena.new_node(Diagram::new(diagram_type));
223                if let Some(pos) = pos {
224                    arena[diagram].set_pos(pos);
225                }
226                as_extension_data_mut!(arena, diagram, Diagram).set_value(lines);
227                let hbl = as_type_data!(arena, code_ref, Block).has_blank_previous_line();
228                as_type_data_mut!(arena, diagram, Block).set_blank_previous_line(hbl);
229                arena[code_ref]
230                    .parent()
231                    .unwrap()
232                    .replace_child(arena, code_ref, diagram);
233            }
234        }
235    }
236}
237
238impl From<DiagramAstTransformer> for AnyAstTransformer {
239    fn from(t: DiagramAstTransformer) -> Self {
240        AnyAstTransformer::Extension(Box::new(t))
241    }
242}
243
244// }}}
245
246// Renderer {{{
247
248const HAS_MERMAID_DIAGRAM: &str = "rushdown-diagram-hmd";
249
250/// Options for the diagram HTML renderer.
251#[derive(Debug, Clone, Default)]
252pub struct DiagramHtmlRendererOptions {
253    pub mermaid: MermaidHtmlRenderingOptions,
254    pub plantuml: PlantUmlHtmlRenderingOptions,
255}
256
257/// Options for the Mermaid diagram HTML renderer.
258#[derive(Debug, Clone)]
259pub enum MermaidHtmlRenderingOptions {
260    /// Use client-side rendering for Mermaid diagrams.
261    Client(ClientSideMermaidHtmlRendereringOptions),
262}
263
264impl Default for MermaidHtmlRenderingOptions {
265    fn default() -> Self {
266        Self::Client(ClientSideMermaidHtmlRendereringOptions::default())
267    }
268}
269
270#[derive(Debug, Clone)]
271pub struct ClientSideMermaidHtmlRendereringOptions {
272    /// URL to the Mermaid JavaScript module. The default is the latest version from jsDelivr CDN.
273    pub mermaid_url: &'static str,
274}
275
276impl Default for ClientSideMermaidHtmlRendereringOptions {
277    fn default() -> Self {
278        Self {
279            mermaid_url: "https://cdn.jsdelivr.net/npm/mermaid@latest/dist/mermaid.esm.min.mjs",
280        }
281    }
282}
283
284/// Options for the PlantUML diagram HTML renderer.
285#[derive(Debug, Clone, Default)]
286pub struct PlantUmlHtmlRenderingOptions {
287    /// `plantuml` command path. If not specified, the renderer will try to find it in the system
288    /// PATH.
289    pub command: String,
290}
291
292impl RendererOptions for DiagramHtmlRendererOptions {}
293
294struct DiagramHtmlRenderer<W: TextWrite> {
295    _phantom: core::marker::PhantomData<W>,
296    options: DiagramHtmlRendererOptions,
297    writer: html::Writer,
298    has_mermaid_diagram: ContextKey<BoolValue>,
299}
300
301impl<W: TextWrite> DiagramHtmlRenderer<W> {
302    fn new(
303        html_opts: html::Options,
304        options: DiagramHtmlRendererOptions,
305        reg: Rc<RefCell<ContextKeyRegistry>>,
306    ) -> Self {
307        let has_mermaid_diagram = reg
308            .borrow_mut()
309            .get_or_create::<BoolValue>(HAS_MERMAID_DIAGRAM);
310        Self {
311            _phantom: core::marker::PhantomData,
312            options,
313            writer: html::Writer::with_options(html_opts),
314            has_mermaid_diagram,
315        }
316    }
317}
318
319impl<W: TextWrite> RenderNode<W> for DiagramHtmlRenderer<W> {
320    fn render_node<'a>(
321        &self,
322        w: &mut W,
323        source: &'a str,
324        arena: &'a Arena,
325        node_ref: NodeRef,
326        entering: bool,
327        ctx: &mut renderer::Context,
328    ) -> Result<WalkStatus> {
329        let kd = as_extension_data!(arena, node_ref, Diagram);
330        match kd.diagram_type {
331            DiagramType::Mermaid => {
332                ctx.insert(self.has_mermaid_diagram, true);
333                if matches!(self.options.mermaid, MermaidHtmlRenderingOptions::Client(_)) {
334                    if entering {
335                        self.writer.write_safe_str(w, "<pre class=\"mermaid\">\n")?;
336                        for line in kd.value().iter(source) {
337                            self.writer.raw_write(w, &line)?;
338                        }
339                    } else {
340                        self.writer.write_safe_str(w, "</pre>\n")?;
341                    }
342                }
343            }
344            DiagramType::PlantUml => {
345                if entering {
346                    let mut buf = String::new();
347                    for line in kd.value().iter(source) {
348                        buf.push_str(&line);
349                    }
350                    match plant_uml(&self.options.plantuml.command, buf.as_bytes(), &[]) {
351                        Ok(svg) => {
352                            self.writer.write_html(w, &String::from_utf8_lossy(&svg))?;
353                        }
354                        Err(e) => {
355                            self.writer.write_html(
356                                w,
357                                &format!(
358                                    "<pre class=\"plantuml-error\">Error rendering PlantUML diagram: {}</pre>",
359                                    e
360                                ),
361                            )?;
362                        }
363                    }
364                }
365            }
366        }
367        Ok(WalkStatus::Continue)
368    }
369}
370
371struct DiagramPostRenderHook<W: TextWrite> {
372    _phantom: core::marker::PhantomData<W>,
373    writer: html::Writer,
374    options: DiagramHtmlRendererOptions,
375
376    has_mermaid_diagram: ContextKey<BoolValue>,
377}
378
379impl<W: TextWrite> DiagramPostRenderHook<W> {
380    pub fn new(
381        html_opts: html::Options,
382        options: DiagramHtmlRendererOptions,
383        reg: Rc<RefCell<ContextKeyRegistry>>,
384    ) -> Self {
385        let has_mermaid_diagram = reg
386            .borrow_mut()
387            .get_or_create::<BoolValue>(HAS_MERMAID_DIAGRAM);
388
389        Self {
390            _phantom: core::marker::PhantomData,
391            writer: html::Writer::with_options(html_opts.clone()),
392            options,
393            has_mermaid_diagram,
394        }
395    }
396}
397
398impl<W: TextWrite> PostRender<W> for DiagramPostRenderHook<W> {
399    fn post_render(
400        &self,
401        w: &mut W,
402        _source: &str,
403        _arena: &Arena,
404        _node_ref: NodeRef,
405        _render: &dyn Render<W>,
406        ctx: &mut renderer::Context,
407    ) -> Result<()> {
408        if *ctx.get(self.has_mermaid_diagram).unwrap_or(&false) {
409            #[allow(irrefutable_let_patterns)]
410            if let MermaidHtmlRenderingOptions::Client(client_opts) = &self.options.mermaid {
411                self.writer.write_html(
412                    w,
413                    &format!(
414                        r#"<script type="module">
415import mermaid from '{}';
416</script>
417"#,
418                        client_opts.mermaid_url
419                    ),
420                )?;
421            }
422        }
423        Ok(())
424    }
425}
426
427impl<'cb, W> NodeRenderer<'cb, W> for DiagramHtmlRenderer<W>
428where
429    W: TextWrite + 'cb,
430{
431    fn register_node_renderer_fn(self, nrr: &mut impl NodeRendererRegistry<'cb, W>) {
432        nrr.register_node_renderer_fn(TypeId::of::<Diagram>(), BoxRenderNode::new(self));
433    }
434}
435
436// }}} Renderer
437
438// Extension {{{
439
440/// Returns a parser extension that parses diagrams.
441pub fn diagram_parser_extension(options: impl Into<DiagramParserOptions>) -> impl ParserExtension {
442    ParserExtensionFn::new(|p: &mut Parser| {
443        p.add_ast_transformer(DiagramAstTransformer::with_options, options.into(), 100);
444    })
445}
446
447/// Returns a renderer extension that renders diagrams in HTML.
448pub fn diagram_html_renderer_extension<'cb, W>(
449    options: impl Into<DiagramHtmlRendererOptions>,
450) -> impl RendererExtension<'cb, W>
451where
452    W: TextWrite + 'cb,
453{
454    RendererExtensionFn::new(move |r: &mut Renderer<'cb, W>| {
455        let options = options.into();
456        r.add_post_render_hook(DiagramPostRenderHook::new, options.clone(), 500);
457        r.add_node_renderer(DiagramHtmlRenderer::new, options);
458    })
459}
460
461// }}}
462
463// Utils {{{
464fn plant_uml(
465    command: impl AsRef<str>,
466    src: &[u8],
467    args: &[&str],
468) -> CoreResult<Vec<u8>, Box<dyn CoreError>> {
469    let path = if command.as_ref().is_empty() {
470        which::which("plantuml")
471    } else {
472        Ok(std::path::PathBuf::from(command.as_ref()))
473    }?;
474
475    let mut params = vec!["-tsvg", "-p", "-Djava.awt.headless=true"];
476    params.extend_from_slice(args);
477
478    let mut cmd = Command::new(path);
479    cmd.args(&params)
480        .env("JAVA_OPTS", "-Djava.awt.headless=true")
481        .stdin(Stdio::piped())
482        .stdout(Stdio::piped())
483        .stderr(Stdio::piped());
484
485    let mut child = cmd.spawn()?;
486    {
487        let stdin = child.stdin.as_mut().ok_or("Failed to open stdin")?;
488        stdin.write_all(src)?;
489    }
490
491    let output = child.wait_with_output()?;
492
493    if output.status.success() {
494        Ok(output.stdout)
495    } else {
496        Err(String::from_utf8_lossy(&output.stderr).into())
497    }
498}
499// }}} Utils