wdl_doc/
lib.rs

1//! Library for generating HTML documentation from WDL files.
2
3#![warn(missing_docs)]
4#![warn(rust_2018_idioms)]
5#![warn(rust_2021_compatibility)]
6#![warn(missing_debug_implementations)]
7#![warn(clippy::missing_docs_in_private_items)]
8#![warn(rustdoc::broken_intra_doc_links)]
9
10pub mod callable;
11pub mod docs_tree;
12pub mod meta;
13pub mod parameter;
14pub mod r#struct;
15
16use std::path::Path;
17use std::path::PathBuf;
18use std::path::absolute;
19use std::rc::Rc;
20
21use anyhow::Result;
22use anyhow::anyhow;
23pub use callable::Callable;
24pub use callable::task;
25pub use callable::workflow;
26pub use docs_tree::DocsTree;
27use docs_tree::HTMLPage;
28use docs_tree::PageType;
29use maud::DOCTYPE;
30use maud::Markup;
31use maud::PreEscaped;
32use maud::Render;
33use maud::html;
34use pathdiff::diff_paths;
35use pulldown_cmark::Options;
36use pulldown_cmark::Parser;
37use wdl_analysis::Analyzer;
38use wdl_analysis::DiagnosticsConfig;
39use wdl_analysis::rules;
40use wdl_ast::AstToken;
41use wdl_ast::SyntaxTokenExt;
42use wdl_ast::VersionStatement;
43use wdl_ast::v1::DocumentItem;
44
45/// The directory where the generated documentation will be stored.
46///
47/// This directory will be created in the workspace directory.
48const DOCS_DIR: &str = "docs";
49
50/// Links to a CSS stylesheet at the given path.
51struct Css<'a>(&'a str);
52
53impl Render for Css<'_> {
54    fn render(&self) -> Markup {
55        html! {
56            link rel="stylesheet" type="text/css" href=(self.0);
57        }
58    }
59}
60
61/// A basic header with a `page_title` and an optional link to the stylesheet.
62pub(crate) fn header<P: AsRef<Path>>(page_title: &str, stylesheet: Option<P>) -> Markup {
63    html! {
64        head {
65            meta charset="utf-8";
66            meta name="viewport" content="width=device-width, initial-scale=1.0";
67            title { (page_title) }
68            link rel="preconnect" href="https://fonts.googleapis.com";
69            link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
70            link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet";
71            @if let Some(ss) = stylesheet {
72                (Css(ss.as_ref().to_str().unwrap()))
73            }
74        }
75    }
76}
77
78/// A full HTML page.
79pub(crate) fn full_page<P: AsRef<Path>>(
80    page_title: &str,
81    body: Markup,
82    stylesheet: Option<P>,
83) -> Markup {
84    html! {
85        (DOCTYPE)
86        html class="dark size-full" {
87            (header(page_title, stylesheet))
88            body class="flex dark size-full dark:bg-slate-950 dark:text-white" {
89                (body)
90            }
91        }
92    }
93}
94
95/// Renders a block of Markdown using `pulldown-cmark`.
96pub(crate) struct Markdown<T>(T);
97
98impl<T: AsRef<str>> Render for Markdown<T> {
99    fn render(&self) -> Markup {
100        // Generate raw HTML
101        let mut unsafe_html = String::new();
102        let mut options = Options::empty();
103        options.insert(Options::ENABLE_TABLES);
104        options.insert(Options::ENABLE_STRIKETHROUGH);
105        let parser = Parser::new_ext(self.0.as_ref(), options);
106        pulldown_cmark::html::push_html(&mut unsafe_html, parser);
107        // Sanitize it with ammonia
108        let safe_html = ammonia::clean(&unsafe_html);
109
110        // Remove the outer `<p>` tag that `pulldown_cmark` wraps single lines in
111        let safe_html = if safe_html.starts_with("<p>") && safe_html.ends_with("</p>\n") {
112            let trimmed = safe_html[3..safe_html.len() - 5].to_string();
113            if trimmed.contains("<p>") {
114                // If the trimmed string contains another `<p>` tag, it means
115                // that the original string was more complicated than a single-line paragraph,
116                // so we should keep the outer `<p>` tag.
117                safe_html
118            } else {
119                trimmed
120            }
121        } else {
122            safe_html
123        };
124        PreEscaped(safe_html)
125    }
126}
127
128/// Parse the preamble comments of a document using the version statement.
129fn parse_preamble_comments(version: VersionStatement) -> String {
130    let comments = version
131        .keyword()
132        .inner()
133        .preceding_trivia()
134        .map(|t| match t.kind() {
135            wdl_ast::SyntaxKind::Comment => match t.to_string().strip_prefix("## ") {
136                Some(comment) => comment.to_string(),
137                None => "".to_string(),
138            },
139            wdl_ast::SyntaxKind::Whitespace => "".to_string(),
140            _ => {
141                panic!("Unexpected token kind: {:?}", t.kind())
142            }
143        })
144        .collect::<Vec<_>>();
145    comments.join("\n")
146}
147
148/// A WDL document.
149#[derive(Debug)]
150pub struct Document {
151    /// The name of the document.
152    name: String,
153    /// The AST node for the version statement.
154    ///
155    /// This is used both to display the WDL version number and to fetch the
156    /// preamble comments.
157    version: VersionStatement,
158    /// The pages that this document should link to.
159    local_pages: Vec<(PathBuf, Rc<HTMLPage>)>,
160}
161
162impl Document {
163    /// Create a new document.
164    pub fn new(
165        name: String,
166        version: VersionStatement,
167        local_pages: Vec<(PathBuf, Rc<HTMLPage>)>,
168    ) -> Self {
169        Self {
170            name,
171            version,
172            local_pages,
173        }
174    }
175
176    /// Get the name of the document.
177    pub fn name(&self) -> &str {
178        &self.name
179    }
180
181    /// Get the version of the document as text.
182    pub fn version(&self) -> String {
183        self.version.version().text().to_string()
184    }
185
186    /// Get the preamble comments of the document.
187    pub fn preamble(&self) -> Markup {
188        let preamble = parse_preamble_comments(self.version.clone());
189        Markdown(&preamble).render()
190    }
191
192    /// Render the document as HTML.
193    pub fn render(&self) -> Markup {
194        html! {
195            div {
196                h1 { (self.name()) }
197                h3 { "WDL Version: " (self.version()) }
198                div { (self.preamble()) }
199                div class="flex flex-col items-center text-left"  {
200                    h2 { "Table of Contents" }
201                    table class="border" {
202                        thead class="border" { tr {
203                            th class="" { "Page" }
204                            th class="" { "Type" }
205                            th class="" { "Description" }
206                        }}
207                        tbody class="border" {
208                            @for page in &self.local_pages {
209                                tr class="border" {
210                                    td class="border" {
211                                        a href=(page.0.to_str().unwrap()) { (page.1.name()) }
212                                    }
213                                    td class="border" {
214                                        @match page.1.page_type() {
215                                            PageType::Index(_) => { "TODO ERROR" }
216                                            PageType::Struct(_) => { "Struct" }
217                                            PageType::Task(_) => { "Task" }
218                                            PageType::Workflow(_) => { "Workflow" }
219                                        }
220                                    }
221                                    td class="border" {
222                                        @match page.1.page_type() {
223                                            PageType::Index(_) => { "TODO ERROR" }
224                                            PageType::Struct(_) => { "N/A" }
225                                            PageType::Task(t) => { (t.description()) }
226                                            PageType::Workflow(w) => { (w.description()) }
227                                        }
228                                    }
229                                }
230                            }
231                        }
232                    }
233                }
234            }
235        }
236    }
237}
238
239/// Generate HTML documentation for a workspace.
240///
241/// This function will generate HTML documentation for all WDL files in the
242/// workspace directory. The generated documentation will be stored in a
243/// `docs` directory within the workspace.
244pub async fn document_workspace(
245    workspace: impl AsRef<Path>,
246    stylesheet: Option<impl AsRef<Path>>,
247    overwrite: bool,
248) -> Result<PathBuf> {
249    let workspace_abs_path = absolute(workspace)?;
250    let stylesheet = stylesheet.and_then(|p| absolute(p.as_ref()).ok());
251
252    if !workspace_abs_path.is_dir() {
253        return Err(anyhow!("Workspace is not a directory"));
254    }
255
256    let docs_dir = workspace_abs_path.join(DOCS_DIR);
257    if overwrite && docs_dir.exists() {
258        std::fs::remove_dir_all(&docs_dir)?;
259    }
260    if !docs_dir.exists() {
261        std::fs::create_dir(&docs_dir)?;
262    }
263
264    let analyzer = Analyzer::new(DiagnosticsConfig::new(rules()), |_: (), _, _, _| async {});
265    analyzer.add_directory(workspace_abs_path.clone()).await?;
266    let results = analyzer.analyze(()).await?;
267
268    let mut docs_tree = if let Some(ss) = stylesheet {
269        docs_tree::DocsTree::new_with_stylesheet(docs_dir.clone(), ss)?
270    } else {
271        docs_tree::DocsTree::new(docs_dir.clone())
272    };
273
274    for result in results {
275        let uri = result.document().uri();
276        let rel_wdl_path = match uri.to_file_path() {
277            Ok(path) => match path.strip_prefix(&workspace_abs_path) {
278                Ok(path) => path.to_path_buf(),
279                Err(_) => {
280                    PathBuf::from("external").join(path.components().skip(1).collect::<PathBuf>())
281                }
282            },
283            Err(_) => PathBuf::from("external").join(
284                uri.path()
285                    .strip_prefix("/")
286                    .expect("URI path should start with /"),
287            ),
288        };
289        let cur_dir = docs_dir.join(rel_wdl_path.with_extension(""));
290        if !cur_dir.exists() {
291            std::fs::create_dir_all(&cur_dir)?;
292        }
293        let ast_doc = result.document().root();
294        let version = ast_doc
295            .version_statement()
296            .expect("document should have a version statement");
297        let ast = ast_doc.ast().unwrap_v1();
298
299        let mut local_pages = Vec::new();
300
301        for item in ast.items() {
302            match item {
303                DocumentItem::Struct(s) => {
304                    let name = s.name().text().to_owned();
305                    let path = cur_dir.join(format!("{}-struct.html", name));
306
307                    let r#struct = r#struct::Struct::new(s.clone());
308
309                    let page = Rc::new(HTMLPage::new(name.clone(), PageType::Struct(r#struct)));
310                    docs_tree.add_page(path.clone(), page.clone());
311                    local_pages.push((diff_paths(path, &cur_dir).unwrap(), page));
312                }
313                DocumentItem::Task(t) => {
314                    let name = t.name().text().to_owned();
315                    let path = cur_dir.join(format!("{}-task.html", name));
316
317                    let task = task::Task::new(
318                        name.clone(),
319                        t.metadata(),
320                        t.parameter_metadata(),
321                        t.input(),
322                        t.output(),
323                        t.runtime(),
324                    );
325
326                    let page = Rc::new(HTMLPage::new(name, PageType::Task(task)));
327                    docs_tree.add_page(path.clone(), page.clone());
328                    local_pages.push((diff_paths(path, &cur_dir).unwrap(), page));
329                }
330                DocumentItem::Workflow(w) => {
331                    let name = w.name().text().to_owned();
332                    let path = cur_dir.join(format!("{}-workflow.html", name));
333
334                    let workflow = workflow::Workflow::new(
335                        name.clone(),
336                        w.metadata(),
337                        w.parameter_metadata(),
338                        w.input(),
339                        w.output(),
340                    );
341
342                    let page = Rc::new(HTMLPage::new(name, PageType::Workflow(workflow)));
343                    docs_tree.add_page(path.clone(), page.clone());
344                    local_pages.push((diff_paths(path, &cur_dir).unwrap(), page));
345                }
346                DocumentItem::Import(_) => {}
347            }
348        }
349        let name = rel_wdl_path.file_stem().unwrap().to_str().unwrap();
350        let document = Document::new(name.to_string(), version, local_pages);
351
352        let index_path = cur_dir.join("index.html");
353
354        docs_tree.add_page(
355            index_path,
356            Rc::new(HTMLPage::new(name.to_string(), PageType::Index(document))),
357        );
358    }
359
360    docs_tree.render_all()?;
361
362    Ok(docs_dir)
363}
364
365#[cfg(test)]
366mod tests {
367    use wdl_ast::Document as AstDocument;
368
369    use super::*;
370
371    #[test]
372    fn test_parse_preamble_comments() {
373        let source = r#"
374        ## This is a comment
375        ## This is also a comment
376        version 1.0
377        workflow test {
378            input {
379                String name
380            }
381            output {
382                String greeting = "Hello, ${name}!"
383            }
384            call say_hello as say_hello {
385                input:
386                    name = name
387            }
388        }
389        "#;
390        let (document, _) = AstDocument::parse(source);
391        let preamble = parse_preamble_comments(document.version_statement().unwrap());
392        assert_eq!(preamble, "This is a comment\nThis is also a comment");
393    }
394
395    #[test]
396    fn test_markdown_render() {
397        let source = r#"
398        ## This is a paragraph.
399        ##
400        ## This is the start of a new paragraph.
401        ## And this is the same paragraph continued.
402        version 1.0
403        workflow test {
404            meta {
405                description: "A simple description should not render with p tags"
406            }
407        }
408        "#;
409        let (document, _) = AstDocument::parse(source);
410        let preamble = parse_preamble_comments(document.version_statement().unwrap());
411        let markdown = Markdown(&preamble).render();
412        assert_eq!(
413            markdown.into_string(),
414            "<p>This is a paragraph.</p>\n<p>This is the start of a new paragraph.\nAnd this is \
415             the same paragraph continued.</p>\n"
416        );
417
418        let doc_item = document.ast().into_v1().unwrap().items().next().unwrap();
419        let ast_workflow = doc_item.into_workflow_definition().unwrap();
420        let workflow = workflow::Workflow::new(
421            ast_workflow.name().text().to_string(),
422            ast_workflow.metadata(),
423            ast_workflow.parameter_metadata(),
424            ast_workflow.input(),
425            ast_workflow.output(),
426        );
427
428        let description = workflow.description();
429        assert_eq!(
430            description.into_string(),
431            "A simple description should not render with p tags"
432        );
433    }
434}