1#![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
45const DOCS_DIR: &str = "docs";
49
50struct 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
61pub(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
78pub(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
95pub(crate) struct Markdown<T>(T);
97
98impl<T: AsRef<str>> Render for Markdown<T> {
99 fn render(&self) -> Markup {
100 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 let safe_html = ammonia::clean(&unsafe_html);
109
110 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 safe_html
118 } else {
119 trimmed
120 }
121 } else {
122 safe_html
123 };
124 PreEscaped(safe_html)
125 }
126}
127
128fn 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#[derive(Debug)]
150pub struct Document {
151 name: String,
153 version: VersionStatement,
158 local_pages: Vec<(PathBuf, Rc<HTMLPage>)>,
160}
161
162impl Document {
163 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 pub fn name(&self) -> &str {
178 &self.name
179 }
180
181 pub fn version(&self) -> String {
183 self.version.version().text().to_string()
184 }
185
186 pub fn preamble(&self) -> Markup {
188 let preamble = parse_preamble_comments(self.version.clone());
189 Markdown(&preamble).render()
190 }
191
192 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
239pub 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}