aquamarine/
attrs.rs

1use include_dir::{include_dir, Dir};
2use itertools::Itertools;
3use proc_macro::Span;
4use proc_macro2::TokenStream;
5use proc_macro_error::{abort, emit_call_site_warning, emit_error};
6use quote::quote;
7use std::fs;
8use std::path::Path;
9use std::{iter, path::PathBuf};
10use syn::{Attribute, Ident, MetaNameValue};
11
12// embedded JS code being inserted as html script elmenets
13static MERMAID_JS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/doc/js/");
14
15// Note: relative path depends on sub-module the macro is invoked in:
16//  base=document.getElementById("rustdoc-vars").attributes["data-root-path"]
17const MERMAID_JS_LOCAL: &str = "static.files.mermaid/mermaid.esm.min.mjs";
18const MERMAID_JS_LOCAL_DIR: &str = "static.files.mermaid";
19const MERMAID_JS_CDN: &str = "https://unpkg.com/mermaid@10/dist/mermaid.esm.min.mjs";
20
21const UNEXPECTED_ATTR_ERROR: &str =
22    "unexpected attribute inside a diagram definition: only #[doc] is allowed";
23
24#[derive(Clone, Default)]
25pub struct Attrs(Vec<Attr>);
26
27#[derive(Clone)]
28pub enum Attr {
29    /// Attribute that is to be forwarded as-is
30    Forward(Attribute),
31    /// Doc comment that cannot be forwarded as-is
32    DocComment(Ident, String),
33    /// Diagram start token
34    DiagramStart(Ident),
35    /// Diagram entry (line)
36    DiagramEntry(Ident, String),
37    /// Diagram end token
38    DiagramEnd(Ident),
39    /// Include Anchor
40    DiagramIncludeAnchor(Ident, PathBuf),
41}
42
43impl Attr {
44    pub fn as_ident(&self) -> Option<&Ident> {
45        match self {
46            Attr::Forward(attr) => attr.path().get_ident(),
47            Attr::DocComment(ident, _) => Some(ident),
48            Attr::DiagramStart(ident) => Some(ident),
49            Attr::DiagramEntry(ident, _) => Some(ident),
50            Attr::DiagramEnd(ident) => Some(ident),
51            Attr::DiagramIncludeAnchor(ident, _) => Some(ident),
52        }
53    }
54
55    pub fn is_diagram_end(&self) -> bool {
56        match self {
57            Attr::DiagramEnd(_) => true,
58            _ => false,
59        }
60    }
61
62    pub fn is_diagram_start(&self) -> bool {
63        match self {
64            Attr::DiagramStart(_) => true,
65            _ => false,
66        }
67    }
68
69    pub fn expect_diagram_entry_text(&self) -> &str {
70        match self {
71            Attr::DiagramEntry(_, body) => body.as_str(),
72            _ => abort!(self.as_ident(), UNEXPECTED_ATTR_ERROR),
73        }
74    }
75}
76impl From<Vec<Attribute>> for Attrs {
77    fn from(attrs: Vec<Attribute>) -> Self {
78        let mut out = Attrs::default();
79        out.push_attrs(attrs);
80        out
81    }
82}
83
84impl quote::ToTokens for Attrs {
85    fn to_tokens(&self, tokens: &mut TokenStream) {
86        let mut attrs = self.0.iter();
87        while let Some(attr) = attrs.next() {
88            match attr {
89                Attr::Forward(attr) => attr.to_tokens(tokens),
90                Attr::DocComment(_, comment) => tokens.extend(quote! {
91                    #[doc = #comment]
92                }),
93                Attr::DiagramStart(_) => {
94                    let diagram = attrs
95                        .by_ref()
96                        .take_while(|x| !x.is_diagram_end())
97                        .map(Attr::expect_diagram_entry_text);
98
99                    tokens.extend(generate_diagram_rustdoc(diagram));
100                }
101                // If that happens, then the parsing stage is faulty: doc comments outside of
102                // in between Start and End tokens are to be emitted as Attr::Forward or Attr::DocComment
103                Attr::DiagramEntry(_, body) => {
104                    emit_call_site_warning!("encountered an unexpected attribute that's going to be ignored, this is a bug! ({})", body);
105                }
106                Attr::DiagramEnd(_) => (),
107                Attr::DiagramIncludeAnchor(_, path) => {
108                    // get cargo manifest dir
109                    let manifest_dir =
110                        std::env::var("CARGO_MANIFEST_DIR").unwrap_or(".".to_string());
111
112                    // append path to cargo manifest dir using PathBuf
113                    let path = &PathBuf::new().join(manifest_dir).join(path);
114
115                    let data = match std::fs::read_to_string(path) {
116                        Ok(data) => data,
117                        Err(e) => {
118                            emit_error!(
119                                Span::call_site(),
120                                "failed to read mermaid file from path {:?}: {}",
121                                path,
122                                e,
123                            );
124                            continue;
125                        }
126                    };
127                    tokens.extend(generate_diagram_rustdoc(Some(data.as_str()).into_iter()))
128                }
129            }
130        }
131    }
132}
133
134fn place_mermaid_js() -> std::io::Result<()> {
135    let target_dir = std::env::var("CARGO_TARGET_DIR").unwrap_or("./target".to_string());
136    let docs_dir = Path::new(&target_dir).join("doc");
137    // extract mermaid module iff rustdoc folder exists already
138    if docs_dir.exists() {
139        let static_files_mermaid_dir = docs_dir.join(MERMAID_JS_LOCAL_DIR);
140        if static_files_mermaid_dir.exists() {
141            Ok(())
142        } else {
143            fs::create_dir_all(&static_files_mermaid_dir).unwrap();
144            MERMAID_JS_DIR.extract(static_files_mermaid_dir)?;
145            Ok(())
146        }
147    } else {
148        // no rustdocs rendering
149        Ok(())
150    }
151}
152
153const MERMAID_INIT_SCRIPT: &str = r#"
154    const mermaidModuleFile = "{mermaidModuleFile}";
155    const fallbackRemoteUrl = "{fallbackRemoteUrl}";
156    const rustdocVarsId= "rustdoc-vars";
157    const dataRootPathAttr = "data-root-path";
158
159
160    function initializeMermaid(mermaid) {
161     var amrn_mermaid_theme =
162         window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
163         ? 'dark'
164         : 'default';
165
166      mermaid.initialize({
167        'startOnLoad':'true',
168        'theme': amrn_mermaid_theme,
169        'logLevel': 3 });
170      mermaid.run();
171    }
172
173	function failedToLoadWarnings() {
174		for(var elem of document.getElementsByClassName("mermaid")) {
175			 elem.innerHTML =
176			 `<div> <mark>
177			  &#9888; Cannot render diagram! Failed to import module from local
178			  file and remote location also!
179			  Either access the rustdocs via HTTP/S using a
180			  <a href="https://developer.mozilla.org/en-US/docs/Learn/Common_questions/Tools_and_setup/set_up_a_local_testing_server">
181			   local web server
182			  </a>, for example:
183			   python3 -m http.server --directory target/doc/, <br> or enable local file access in your
184			   Safari/Firefox/Chrome browser, for example
185			  starting Chrome with flag '--allow-file-access-from-files'.
186			  </mark></div> `;
187		}
188	}
189
190
191    // If rustdoc is read from file directly, the import of mermaid module
192    // from file will fail. In this case falling back to remote location.
193    // If neither succeeds, the mermaid markdown is replaced by notice to
194    // enable file acecss in browser.
195    try {
196       var rootPath = document
197         .getElementById(rustdocVarsId)
198         .attributes[dataRootPathAttr]
199         .value;
200       const {
201         default: mermaid,
202       } = await import(rootPath + mermaidModuleFile);
203	   initializeMermaid(mermaid);
204    } catch (e) {
205       try {
206         const {
207            default: mermaid,
208         } = await import(fallbackRemoteUrl);
209	     initializeMermaid(mermaid);
210       } catch (e) {
211		 failedToLoadWarnings();
212	   }
213    }
214"#;
215
216fn generate_diagram_rustdoc<'a>(parts: impl Iterator<Item = &'a str>) -> TokenStream {
217    let preamble = iter::once(r#"<div class="mermaid">"#);
218    let postamble = iter::once("</div>");
219
220    let mermaid_js_init = format!(
221        r#"<script type="module">{}</script>"#,
222        MERMAID_INIT_SCRIPT
223            .replace("{mermaidModuleFile}", MERMAID_JS_LOCAL)
224            .replace("{fallbackRemoteUrl}", MERMAID_JS_CDN)
225    );
226
227    let body = preamble.chain(parts).chain(postamble).join("\n");
228
229    place_mermaid_js().unwrap_or_else(|e| {
230        eprintln!("failed to place mermaid.js on the filesystem: {}", e);
231    });
232
233    quote! {
234        #[doc = #mermaid_js_init]
235        #[doc = #body]
236    }
237}
238
239impl Attrs {
240    pub fn push_attrs(&mut self, attrs: Vec<Attribute>) {
241        use syn::Expr;
242        use syn::ExprLit;
243        use syn::Lit::*;
244
245        let mut current_location = Location::OutsideDiagram;
246        let mut diagram_start_ident = None;
247
248        for attr in attrs {
249            match attr.meta.require_name_value() {
250                Ok(MetaNameValue {
251                    value: Expr::Lit(ExprLit { lit: Str(s), .. }),
252                    path,
253                    ..
254                }) if path.is_ident("doc") => {
255                    let ident = path.get_ident().unwrap();
256                    for attr in split_attr_body(ident, &s.value(), &mut current_location) {
257                        if attr.is_diagram_start() {
258                            diagram_start_ident.replace(ident.clone());
259                        }
260                        self.0.push(attr);
261                    }
262                }
263                _ => {
264                    if let Location::InsideDiagram = current_location {
265                        abort!(attr, UNEXPECTED_ATTR_ERROR)
266                    } else {
267                        self.0.push(Attr::Forward(attr))
268                    }
269                }
270            }
271        }
272
273        if current_location.is_inside() {
274            abort!(diagram_start_ident, "diagram code block is not terminated");
275        }
276    }
277}
278
279#[derive(Debug, Copy, Clone, Eq, PartialEq)]
280enum Location {
281    OutsideDiagram,
282    InsideDiagram,
283}
284
285impl Location {
286    fn is_inside(self) -> bool {
287        match self {
288            Location::InsideDiagram => true,
289            _ => false,
290        }
291    }
292}
293
294fn split_attr_body(ident: &Ident, input: &str, loc: &mut Location) -> Vec<Attr> {
295    use self::Location::*;
296
297    const TICKS: &str = "```";
298    const MERMAID: &str = "mermaid";
299
300    let mut tokens = tokenize_doc_str(input).peekable();
301
302    // Special case: empty strings outside the diagram span should be still generated
303    if tokens.peek().is_none() && !loc.is_inside() {
304        return vec![Attr::DocComment(ident.clone(), String::new())];
305    };
306
307    // To aid rustc with type inference in closures
308    #[derive(Default)]
309    struct Ctx<'a> {
310        attrs: Vec<Attr>,
311        buffer: Vec<&'a str>,
312    }
313
314    let mut ctx: Ctx<'_> = Default::default();
315
316    let flush_buffer_as_doc_comment = |ctx: &mut Ctx| {
317        if !ctx.buffer.is_empty() {
318            ctx.attrs.push(Attr::DocComment(
319                ident.clone(),
320                ctx.buffer.drain(..).join(" "),
321            ));
322        }
323    };
324
325    let flush_buffer_as_diagram_entry = |ctx: &mut Ctx| {
326        let s = ctx.buffer.drain(..).join(" ");
327        if !s.trim().is_empty() {
328            ctx.attrs.push(Attr::DiagramEntry(ident.clone(), s));
329        }
330    };
331
332    while let Some(token) = tokens.next() {
333        match (*loc, token, tokens.peek()) {
334            // Detect include anchor
335            (OutsideDiagram, token, _) if token.starts_with("include_mmd!") => {
336                // cleanup
337                let path = token.trim_start_matches("include_mmd!").trim();
338                let path = path.trim_start_matches('(').trim_end_matches(')');
339                let path = path.trim_matches('"');
340                let path = PathBuf::from(path);
341                ctx.attrs
342                    .push(Attr::DiagramIncludeAnchor(ident.clone(), path));
343            }
344            // Flush the buffer, then open the diagram code block
345            (OutsideDiagram, TICKS, Some(&MERMAID)) => {
346                tokens.next();
347                *loc = InsideDiagram;
348                flush_buffer_as_doc_comment(&mut ctx);
349                ctx.attrs.push(Attr::DiagramStart(ident.clone()));
350            }
351            // Flush the buffer, close the code block
352            (InsideDiagram, TICKS, _) => {
353                *loc = OutsideDiagram;
354                flush_buffer_as_diagram_entry(&mut ctx);
355                ctx.attrs.push(Attr::DiagramEnd(ident.clone()))
356            }
357            _ => ctx.buffer.push(token),
358        }
359    }
360
361    if !ctx.buffer.is_empty() {
362        if loc.is_inside() {
363            flush_buffer_as_diagram_entry(&mut ctx);
364        } else {
365            flush_buffer_as_doc_comment(&mut ctx);
366        };
367    }
368
369    ctx.attrs
370}
371
372fn tokenize_doc_str(input: &str) -> impl Iterator<Item = &str> {
373    const TICKS: &str = "```";
374    split_inclusive(input, TICKS).flat_map(|token| {
375        // not str::split_whitespace because we don't wanna filter-out the whitespace tokens
376        token.split(' ')
377    })
378}
379
380// TODO: remove once str::split_inclusive is stable
381fn split_inclusive<'a, 'b: 'a>(input: &'a str, delim: &'b str) -> impl Iterator<Item = &'a str> {
382    let mut tokens = vec![];
383    let mut prev = 0;
384
385    for (idx, matches) in input.match_indices(delim) {
386        tokens.extend(nonempty(&input[prev..idx]));
387
388        prev = idx + matches.len();
389
390        tokens.push(matches);
391    }
392
393    if prev < input.len() {
394        tokens.push(&input[prev..]);
395    }
396
397    tokens.into_iter()
398}
399
400fn nonempty(s: &str) -> Option<&str> {
401    if s.is_empty() {
402        None
403    } else {
404        Some(s)
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::{split_inclusive, Attr};
411    use std::fmt;
412
413    #[cfg(test)]
414    impl fmt::Debug for Attr {
415        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416            match self {
417                Attr::Forward(..) => f.write_str("Attr::Forward"),
418                Attr::DocComment(_, body) => write!(f, "Attr::DocComment({:?})", body),
419                Attr::DiagramStart(..) => f.write_str("Attr::DiagramStart"),
420                Attr::DiagramEntry(_, body) => write!(f, "Attr::DiagramEntry({:?})", body),
421                Attr::DiagramEnd(..) => f.write_str("Attr::DiagramEnd"),
422                Attr::DiagramIncludeAnchor(_, path) => {
423                    write!(f, "Attr::DiagramIncludeAnchor({:?})", path)
424                }
425            }
426        }
427    }
428
429    #[cfg(test)]
430    impl Eq for Attr {}
431
432    #[cfg(test)]
433    impl PartialEq for Attr {
434        fn eq(&self, other: &Self) -> bool {
435            use std::mem::discriminant;
436            use Attr::*;
437            match (self, other) {
438                (DocComment(_, a), DocComment(_, b)) => a == b,
439                (DiagramEntry(_, a), DiagramEntry(_, b)) => a == b,
440                (a, b) => discriminant(a) == discriminant(b),
441            }
442        }
443    }
444
445    #[test]
446    fn temp_split_inclusive() {
447        let src = "```";
448        let out: Vec<_> = split_inclusive(src, "```").collect();
449        assert_eq!(&out, &["```",]);
450
451        let src = "```abcd```";
452        let out: Vec<_> = split_inclusive(src, "```").collect();
453        assert_eq!(&out, &["```", "abcd", "```"]);
454
455        let src = "left```abcd```right";
456        let out: Vec<_> = split_inclusive(src, "```").collect();
457        assert_eq!(&out, &["left", "```", "abcd", "```", "right",]);
458    }
459
460    mod split_attr_body_tests {
461        use super::super::*;
462
463        use proc_macro2::Ident;
464        use proc_macro2::Span;
465
466        use pretty_assertions::assert_eq;
467
468        fn i() -> Ident {
469            Ident::new("fake", Span::call_site())
470        }
471
472        struct TestCase<'a> {
473            ident: Ident,
474            location: Location,
475            input: &'a str,
476            expect_location: Location,
477            expect_attrs: Vec<Attr>,
478        }
479
480        fn check(case: TestCase) {
481            let mut loc = case.location;
482            let attrs = split_attr_body(&case.ident, case.input, &mut loc);
483            assert_eq!(loc, case.expect_location);
484            assert_eq!(attrs, case.expect_attrs);
485        }
486
487        #[test]
488        fn one_line_one_diagram() {
489            let case = TestCase {
490                ident: i(),
491                location: Location::OutsideDiagram,
492                input: "```mermaid abcd```",
493                expect_location: Location::OutsideDiagram,
494                expect_attrs: vec![
495                    Attr::DiagramStart(i()),
496                    Attr::DiagramEntry(i(), "abcd".into()),
497                    Attr::DiagramEnd(i()),
498                ],
499            };
500
501            check(case)
502        }
503
504        #[test]
505        fn one_line_multiple_diagrams() {
506            let case = TestCase {
507                ident: i(),
508                location: Location::OutsideDiagram,
509                input: "```mermaid abcd``` ```mermaid efgh``` ```mermaid ijkl```",
510                expect_location: Location::OutsideDiagram,
511                expect_attrs: vec![
512                    Attr::DiagramStart(i()),
513                    Attr::DiagramEntry(i(), "abcd".into()),
514                    Attr::DiagramEnd(i()),
515                    Attr::DocComment(i(), " ".into()),
516                    Attr::DiagramStart(i()),
517                    Attr::DiagramEntry(i(), "efgh".into()),
518                    Attr::DiagramEnd(i()),
519                    Attr::DocComment(i(), " ".into()),
520                    Attr::DiagramStart(i()),
521                    Attr::DiagramEntry(i(), "ijkl".into()),
522                    Attr::DiagramEnd(i()),
523                ],
524            };
525
526            check(case)
527        }
528
529        #[test]
530        fn other_snippet() {
531            let case = TestCase {
532                ident: i(),
533                location: Location::OutsideDiagram,
534                input: "```rust panic!()```",
535                expect_location: Location::OutsideDiagram,
536                expect_attrs: vec![Attr::DocComment(i(), "``` rust panic!() ```".into())],
537            };
538
539            check(case)
540        }
541
542        #[test]
543        fn carry_over() {
544            let case = TestCase {
545                ident: i(),
546                location: Location::OutsideDiagram,
547                input: "left```mermaid abcd```right",
548                expect_location: Location::OutsideDiagram,
549                expect_attrs: vec![
550                    Attr::DocComment(i(), "left".into()),
551                    Attr::DiagramStart(i()),
552                    Attr::DiagramEntry(i(), "abcd".into()),
553                    Attr::DiagramEnd(i()),
554                    Attr::DocComment(i(), "right".into()),
555                ],
556            };
557
558            check(case)
559        }
560
561        #[test]
562        fn multiline_termination() {
563            let case = TestCase {
564                ident: i(),
565                location: Location::InsideDiagram,
566                input: "abcd```",
567                expect_location: Location::OutsideDiagram,
568                expect_attrs: vec![
569                    Attr::DiagramEntry(i(), "abcd".into()),
570                    Attr::DiagramEnd(i()),
571                ],
572            };
573
574            check(case)
575        }
576
577        #[test]
578        fn multiline_termination_single_token() {
579            let case = TestCase {
580                ident: i(),
581                location: Location::InsideDiagram,
582                input: "```",
583                expect_location: Location::OutsideDiagram,
584                expect_attrs: vec![Attr::DiagramEnd(i())],
585            };
586
587            check(case)
588        }
589
590        #[test]
591        fn multiline_termination_carry() {
592            let case = TestCase {
593                ident: i(),
594                location: Location::InsideDiagram,
595                input: "abcd```right",
596                expect_location: Location::OutsideDiagram,
597                expect_attrs: vec![
598                    Attr::DiagramEntry(i(), "abcd".into()),
599                    Attr::DiagramEnd(i()),
600                    Attr::DocComment(i(), "right".into()),
601                ],
602            };
603
604            check(case)
605        }
606    }
607}