1use crate::{
2 config::{ProjectConfig, Repository},
3 module::CheckedModule,
4};
5use aiken_lang::{
6 ast::{
7 DataType, Definition, Function, ModuleConstant, RecordConstructor, Span, TypeAlias,
8 TypedDefinition,
9 },
10 format,
11 parser::extra::Comment,
12 tipo::Type,
13};
14use askama::Template;
15use itertools::Itertools;
16use pulldown_cmark as markdown;
17use regex::Regex;
18use serde::Serialize;
19use serde_json as json;
20use std::{
21 path::{Path, PathBuf},
22 rc::Rc,
23 time::{Duration, SystemTime},
24};
25
26const MAX_COLUMNS: isize = 80;
27const VERSION: &str = env!("CARGO_PKG_VERSION");
28
29pub mod link_tree;
30pub mod source_links;
31
32#[derive(Debug, PartialEq, Eq, Clone)]
33pub struct DocFile {
34 pub path: PathBuf,
35 pub content: String,
36}
37
38#[derive(Template)]
39#[template(path = "module.html")]
40struct ModuleTemplate<'a> {
41 aiken_version: &'a str,
42 breadcrumbs: String,
43 page_title: &'a str,
44 module_name: String,
45 project_name: &'a str,
46 project_version: &'a str,
47 modules: &'a [DocLink],
48 functions: Vec<Interspersed>,
49 types: Vec<DocType>,
50 constants: Vec<DocConstant>,
51 documentation: String,
52 source: &'a DocLink,
53 timestamp: String,
54}
55
56impl ModuleTemplate<'_> {
57 pub fn is_current_module(&self, module: &DocLink) -> bool {
58 match module.path.split(".html").next() {
59 None => false,
60 Some(name) => self.module_name == name,
61 }
62 }
63}
64
65#[derive(Template)]
66#[template(path = "page.html")]
67struct PageTemplate<'a> {
68 aiken_version: &'a str,
69 breadcrumbs: &'a str,
70 page_title: &'a str,
71 project_name: &'a str,
72 project_version: &'a str,
73 modules: &'a [DocLink],
74 content: String,
75 source: &'a DocLink,
76 timestamp: &'a str,
77}
78
79impl PageTemplate<'_> {
80 pub fn is_current_module(&self, _module: &DocLink) -> bool {
81 false
82 }
83}
84
85#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
86pub struct DocLink {
87 indent: usize,
88 name: String,
89 path: String,
90}
91
92impl DocLink {
93 pub fn is_empty(&self) -> bool {
94 self.name.is_empty()
95 }
96
97 pub fn is_separator(&self) -> bool {
98 self.path.is_empty()
99 }
100}
101
102pub fn generate_all(
108 root: &Path,
109 config: &ProjectConfig,
110 modules: Vec<&CheckedModule>,
111) -> Vec<DocFile> {
112 let timestamp = new_timestamp();
113 let modules_links = generate_modules_links(&modules);
114
115 let source = match &config.repository {
116 None => DocLink {
117 indent: 0,
118 name: String::new(),
119 path: String::new(),
120 },
121 Some(Repository {
122 user,
123 project,
124 platform,
125 }) => DocLink {
126 indent: 0,
127 name: format!("{user}/{project}"),
128 path: format!("https://{platform}.com/{user}/{project}"),
129 },
130 };
131
132 let mut output_files: Vec<DocFile> = vec![];
133 let mut search_indexes: Vec<SearchIndex> = vec![];
134
135 for module in &modules {
136 if module.skip_doc_generation() {
137 continue;
138 }
139
140 let (indexes, file) =
141 generate_module(root, config, module, &modules_links, &source, ×tamp);
142 if !indexes.is_empty() {
143 search_indexes.extend(indexes);
144 output_files.push(file);
145 }
146 }
147
148 output_files.extend(generate_static_assets(search_indexes));
149 output_files.push(generate_readme(
150 root,
151 config,
152 &modules_links,
153 &source,
154 ×tamp,
155 ));
156
157 output_files
158}
159
160fn generate_module(
161 root: &Path,
162 config: &ProjectConfig,
163 module: &CheckedModule,
164 modules: &[DocLink],
165 source: &DocLink,
166 timestamp: &Duration,
167) -> (Vec<SearchIndex>, DocFile) {
168 let mut search_indexes = vec![];
169
170 let source_linker = source_links::SourceLinker::new(root, config, module);
171
172 let mut section_headers = module
174 .extra
175 .comments
176 .iter()
177 .filter_map(|span| {
178 let comment = Comment::from((span, module.code.as_str()))
179 .content
180 .trim_start();
181 if comment.starts_with('#') {
182 let trimmed = comment.trim_start_matches('#');
183 let heading = comment.len() - trimmed.len();
184 Some((
185 span,
186 DocSection {
187 heading,
188 title: trimmed.trim_start().to_string(),
189 },
190 ))
191 } else {
192 None
193 }
194 })
195 .collect_vec();
196
197 let functions: Vec<(Span, DocFunction)> = module
199 .ast
200 .definitions
201 .iter()
202 .flat_map(|def| DocFunction::from_definition(def, &source_linker))
203 .collect();
204
205 functions.iter().for_each(|(_, function)| {
206 search_indexes.push(SearchIndex::from_function(module, function))
207 });
208
209 let no_functions = functions.is_empty();
210
211 let mut functions_and_headers = Vec::new();
212
213 for (span_fn, function) in functions {
214 let mut to_remove = vec![];
215 for (ix, (span_h, header)) in section_headers.iter().enumerate() {
216 if span_h.start < span_fn.start {
217 functions_and_headers.push(Interspersed::Section(header.clone()));
218 to_remove.push(ix);
219 }
220 }
221
222 for ix in to_remove.iter().rev() {
223 section_headers.remove(*ix);
224 }
225
226 functions_and_headers.push(Interspersed::Function(function))
227 }
228
229 let types: Vec<DocType> = module
231 .ast
232 .definitions
233 .iter()
234 .flat_map(|def| DocType::from_definition(def, &source_linker))
235 .sorted()
236 .collect();
237 types
238 .iter()
239 .for_each(|type_info| search_indexes.push(SearchIndex::from_type(module, type_info)));
240
241 let constants: Vec<DocConstant> = module
243 .ast
244 .definitions
245 .iter()
246 .flat_map(|def| DocConstant::from_definition(def, &source_linker))
247 .sorted()
248 .collect();
249 constants
250 .iter()
251 .for_each(|constant| search_indexes.push(SearchIndex::from_constant(module, constant)));
252
253 let is_empty = no_functions && types.is_empty() && constants.is_empty();
254
255 if !is_empty {
257 search_indexes.push(SearchIndex::from_module(module));
258 }
259
260 let module = ModuleTemplate {
261 aiken_version: VERSION,
262 breadcrumbs: to_breadcrumbs(&module.name),
263 documentation: render_markdown(&module.ast.docs.iter().join("\n")),
264 modules,
265 project_name: &config.name.repo.to_string(),
266 page_title: &format!("{} - {}", module.name, config.name),
267 module_name: module.name.clone(),
268 project_version: &config.version.to_string(),
269 functions: functions_and_headers,
270 types,
271 constants,
272 source,
273 timestamp: timestamp.as_secs().to_string(),
274 };
275
276 let rendered_content = convert_latex_markers(
277 module
278 .render()
279 .expect("Module documentation template rendering"),
280 );
281
282 (
283 search_indexes,
284 DocFile {
285 path: PathBuf::from(format!("{}.html", module.module_name)),
286 content: rendered_content,
287 },
288 )
289}
290
291#[cfg(windows)]
292fn convert_latex_markers(input: String) -> String {
293 input
294}
295
296#[cfg(not(windows))]
297fn convert_latex_markers(input: String) -> String {
298 let re_inline = Regex::new(r#"<span class="math math-inline">\s*(.+?)\s*</span>"#).unwrap();
299 let re_block = Regex::new(r#"<span class="math math-display">\s*(.+?)\s*</span>"#).unwrap();
300
301 let opts_inline = katex::Opts::builder()
302 .display_mode(false) .output_type(katex::OutputType::Mathml)
304 .build()
305 .unwrap();
306
307 let opts_block = katex::Opts::builder()
308 .display_mode(true) .output_type(katex::OutputType::Mathml)
310 .build()
311 .unwrap();
312
313 let input = re_inline.replace_all(&input, |caps: ®ex::Captures| {
314 let formula = &caps[1];
315 katex::render_with_opts(formula, &opts_inline).unwrap_or_else(|_| formula.to_string())
316 });
317
318 re_block
319 .replace_all(&input, |caps: ®ex::Captures| {
320 let formula = &caps[1];
321 katex::render_with_opts(formula, &opts_block).unwrap_or_else(|_| formula.to_string())
322 })
323 .to_string()
324}
325
326fn generate_static_assets(search_indexes: Vec<SearchIndex>) -> Vec<DocFile> {
327 let mut assets: Vec<DocFile> = vec![];
328
329 assets.push(DocFile {
330 path: PathBuf::from("favicon.svg"),
331 content: std::include_str!("../templates/favicon.svg").to_string(),
332 });
333
334 assets.push(DocFile {
335 path: PathBuf::from("css/atom-one-light.min.css"),
336 content: std::include_str!("../templates/css/atom-one-light.min.css").to_string(),
337 });
338
339 assets.push(DocFile {
340 path: PathBuf::from("css/atom-one-dark.min.css"),
341 content: std::include_str!("../templates/css/atom-one-dark.min.css").to_string(),
342 });
343
344 assets.push(DocFile {
345 path: PathBuf::from("css/index.css"),
346 content: std::include_str!("../templates/css/index.css").to_string(),
347 });
348
349 assets.push(DocFile {
350 path: PathBuf::from("js/highlight.min.js"),
351 content: std::include_str!("../templates/js/highlight.min.js").to_string(),
352 });
353
354 assets.push(DocFile {
355 path: PathBuf::from("js/highlightjs-aiken.js"),
356 content: std::include_str!("../templates/js/highlightjs-aiken.js").to_string(),
357 });
358
359 assets.push(DocFile {
360 path: PathBuf::from("js/lunr.min.js"),
361 content: std::include_str!("../templates/js/lunr.min.js").to_string(),
362 });
363
364 assets.push(DocFile {
365 path: PathBuf::from("js/index.js"),
366 content: std::include_str!("../templates/js/index.js").to_string(),
367 });
368
369 assets.push(DocFile {
370 path: PathBuf::from("search-data.js"),
371 content: format!(
372 "window.Aiken.initSearch({});",
373 json::to_string(&escape_html_contents(search_indexes))
374 .expect("search index serialization")
375 ),
376 });
377
378 assets
379}
380
381fn generate_readme(
382 root: &Path,
383 config: &ProjectConfig,
384 modules: &[DocLink],
385 source: &DocLink,
386 timestamp: &Duration,
387) -> DocFile {
388 let path = PathBuf::from("index.html");
389
390 let content = std::fs::read_to_string(root.join("README.md")).unwrap_or_default();
391
392 let template = PageTemplate {
393 aiken_version: VERSION,
394 breadcrumbs: ".",
395 modules,
396 project_name: &config.name.repo.to_string(),
397 page_title: &config.name.to_string(),
398 project_version: &config.version.to_string(),
399 content: render_markdown(&content),
400 source,
401 timestamp: ×tamp.as_secs().to_string(),
402 };
403
404 DocFile {
405 path,
406 content: template.render().expect("Page template rendering"),
407 }
408}
409
410fn generate_modules_links(modules: &[&CheckedModule]) -> Vec<DocLink> {
411 let non_empty_modules = modules
412 .iter()
413 .filter(|module| {
414 !module.skip_doc_generation()
415 && module.ast.definitions.iter().any(|def| {
416 matches!(
417 def,
418 Definition::Fn(Function { public: true, .. })
419 | Definition::DataType(DataType { public: true, .. })
420 | Definition::TypeAlias(TypeAlias { public: true, .. })
421 | Definition::ModuleConstant(ModuleConstant { public: true, .. })
422 )
423 })
424 })
425 .sorted_by(|a, b| a.name.cmp(&b.name))
426 .collect_vec();
427
428 let mut links = link_tree::LinkTree::default();
429
430 for module in non_empty_modules {
431 links.insert(module.name.as_str());
432 }
433
434 links.to_vec()
435}
436
437#[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
438struct SearchIndex {
439 doc: String,
440 title: String,
441 content: String,
442 url: String,
443}
444
445impl SearchIndex {
446 fn from_function(module: &CheckedModule, function: &DocFunction) -> Self {
447 SearchIndex {
448 doc: module.name.to_string(),
449 title: function.name.to_string(),
450 content: format!("{}\n{}", function.signature, function.raw_documentation),
451 url: format!("{}.html#{}", module.name, function.name),
452 }
453 }
454
455 fn from_type(module: &CheckedModule, type_info: &DocType) -> Self {
456 let constructors = type_info
457 .constructors
458 .iter()
459 .map(|constructor| {
460 format!(
461 "{}\n{}",
462 constructor.definition, constructor.raw_documentation
463 )
464 })
465 .join("\n");
466
467 SearchIndex {
468 doc: module.name.to_string(),
469 title: type_info.name.to_string(),
470 content: format!(
471 "{}\n{}\n{}",
472 type_info.definition, type_info.raw_documentation, constructors,
473 ),
474 url: format!("{}.html#{}", module.name, type_info.name),
475 }
476 }
477
478 fn from_constant(module: &CheckedModule, constant: &DocConstant) -> Self {
479 SearchIndex {
480 doc: module.name.to_string(),
481 title: constant.name.to_string(),
482 content: format!("{}\n{}", constant.definition, constant.raw_documentation),
483 url: format!("{}.html#{}", module.name, constant.name),
484 }
485 }
486
487 fn from_module(module: &CheckedModule) -> Self {
488 SearchIndex {
489 doc: module.name.to_string(),
490 title: module.name.to_string(),
491 content: module.ast.docs.iter().join("\n"),
492 url: format!("{}.html", module.name),
493 }
494 }
495}
496
497#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
498enum Interspersed {
499 Section(DocSection),
500 Function(DocFunction),
501}
502
503#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
504struct DocSection {
505 heading: usize,
506 title: String,
507}
508
509#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
510struct DocFunction {
511 name: String,
512 signature: String,
513 documentation: String,
514 raw_documentation: String,
515 source_url: String,
516}
517
518impl DocFunction {
519 fn from_definition(
520 def: &TypedDefinition,
521 source_linker: &source_links::SourceLinker,
522 ) -> Option<(Span, Self)> {
523 match def {
524 Definition::Fn(func_def) if func_def.public => Some((
525 func_def.location,
526 DocFunction {
527 name: func_def.name.clone(),
528 documentation: func_def
529 .doc
530 .as_deref()
531 .map(render_markdown)
532 .unwrap_or_default(),
533 raw_documentation: func_def.doc.as_deref().unwrap_or_default().to_string(),
534 signature: format::Formatter::new()
535 .docs_fn_signature(
536 &func_def.name,
537 &func_def.arguments,
538 &func_def.return_annotation,
539 func_def.return_type.clone(),
540 )
541 .to_pretty_string(MAX_COLUMNS),
542 source_url: source_linker
543 .url(func_def.location.map_end(|_| func_def.end_position)),
544 },
545 )),
546 _ => None,
547 }
548 }
549}
550
551#[derive(PartialEq, Eq, PartialOrd, Ord)]
552struct DocConstant {
553 name: String,
554 definition: String,
555 documentation: String,
556 raw_documentation: String,
557 source_url: String,
558}
559
560impl DocConstant {
561 fn from_definition(
562 def: &TypedDefinition,
563 source_linker: &source_links::SourceLinker,
564 ) -> Option<Self> {
565 match def {
566 Definition::ModuleConstant(const_def) if const_def.public => Some(DocConstant {
567 name: const_def.name.clone(),
568 documentation: const_def
569 .doc
570 .as_deref()
571 .map(render_markdown)
572 .unwrap_or_default(),
573 raw_documentation: const_def.doc.as_deref().unwrap_or_default().to_string(),
574 definition: format::Formatter::new()
575 .docs_const_expr(&const_def.name, &const_def.value)
576 .to_pretty_string(MAX_COLUMNS),
577 source_url: source_linker.url(const_def.location),
578 }),
579 _ => None,
580 }
581 }
582}
583
584#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
585struct DocType {
586 name: String,
587 definition: String,
588 documentation: String,
589 raw_documentation: String,
590 constructors: Vec<DocTypeConstructor>,
591 parameters: Vec<String>,
592 opaque: bool,
593 source_url: String,
594}
595
596impl DocType {
597 fn from_definition(
598 def: &TypedDefinition,
599 source_linker: &source_links::SourceLinker,
600 ) -> Option<Self> {
601 match def {
602 Definition::TypeAlias(info) if info.public => Some(DocType {
603 name: info.alias.clone(),
604 definition: format::Formatter::new()
605 .docs_type_alias(&info.alias, &info.parameters, &info.annotation)
606 .to_pretty_string(MAX_COLUMNS),
607 documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(),
608 raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(),
609 constructors: vec![],
610 parameters: info.parameters.clone(),
611 opaque: false,
612 source_url: source_linker.url(info.location),
613 }),
614
615 Definition::DataType(info) if info.public && !info.opaque => Some(DocType {
616 name: info.name.clone(),
617 definition: format::Formatter::new()
618 .docs_data_type(
619 &info.name,
620 &info.parameters,
621 &info.constructors,
622 &info.location,
623 )
624 .to_pretty_string(MAX_COLUMNS),
625 documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(),
626 raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(),
627 constructors: info
628 .constructors
629 .iter()
630 .map(DocTypeConstructor::from_record_constructor)
631 .collect(),
632 parameters: info.parameters.clone(),
633 opaque: info.opaque,
634 source_url: source_linker.url(info.location),
635 }),
636
637 Definition::DataType(info) if info.public && info.opaque => Some(DocType {
638 name: info.name.clone(),
639 definition: format::Formatter::new()
640 .docs_opaque_data_type(&info.name, &info.parameters, &info.location)
641 .to_pretty_string(MAX_COLUMNS),
642 documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(),
643 raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(),
644 constructors: vec![],
645 parameters: info.parameters.clone(),
646 opaque: info.opaque,
647 source_url: source_linker.url(info.location),
648 }),
649
650 _ => None,
651 }
652 }
653}
654
655#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
656struct DocTypeConstructor {
657 definition: String,
658 documentation: String,
659 raw_documentation: String,
660}
661
662impl DocTypeConstructor {
663 fn from_record_constructor(constructor: &RecordConstructor<Rc<Type>>) -> Self {
664 let doc_args = constructor
665 .arguments
666 .iter()
667 .filter_map(|arg| match (arg.label.as_deref(), arg.doc.as_deref()) {
668 (Some(label), Some(doc)) => Some(format!("#### `.{label}`\n{doc}\n<hr/>\n",)),
669 _ => None,
670 })
671 .join("\n");
672
673 DocTypeConstructor {
674 definition: format::Formatter::new()
675 .docs_record_constructor(constructor)
676 .to_pretty_string(format::MAX_COLUMNS),
677 documentation: constructor
678 .doc
679 .as_deref()
680 .map(|doc| render_markdown(&format!("{doc}\n{doc_args}")))
681 .or(if doc_args.is_empty() {
682 None
683 } else {
684 Some(render_markdown(&format!("\n{doc_args}")))
685 })
686 .unwrap_or_default(),
687 raw_documentation: constructor.doc.as_deref().unwrap_or_default().to_string(),
688 }
689 }
690}
691
692fn render_markdown(text: &str) -> String {
695 let mut s = String::with_capacity(text.len() * 3 / 2);
696 let p = markdown::Parser::new_ext(text, markdown::Options::all());
697 markdown::html::push_html(&mut s, p);
698 s
699}
700
701fn escape_html_contents(indexes: Vec<SearchIndex>) -> Vec<SearchIndex> {
702 fn escape_html_content(it: String) -> String {
703 it.replace('&', "&")
704 .replace('<', "<")
705 .replace('>', ">")
706 .replace('\"', """)
707 .replace('\'', "'")
708 }
709
710 indexes
711 .into_iter()
712 .map(|idx| SearchIndex {
713 doc: idx.doc,
714 title: idx.title,
715 content: escape_html_content(idx.content),
716 url: idx.url,
717 })
718 .collect::<Vec<SearchIndex>>()
719}
720
721fn new_timestamp() -> Duration {
722 SystemTime::now()
723 .duration_since(SystemTime::UNIX_EPOCH)
724 .expect("get current timestamp")
725}
726
727fn to_breadcrumbs(path: &str) -> String {
728 let breadcrumbs = path
729 .strip_prefix('/')
730 .unwrap_or(path)
731 .split('/')
732 .skip(1)
733 .map(|_| "..")
734 .join("/");
735 if breadcrumbs.is_empty() {
736 ".".to_string()
737 } else {
738 breadcrumbs
739 }
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 #[test]
747 fn to_breadcrumbs_test() {
748 assert_eq!(to_breadcrumbs("a.html"), ".");
750 assert_eq!(to_breadcrumbs("/a.html"), ".");
751 assert_eq!(to_breadcrumbs("/a/b.html"), "..");
752 assert_eq!(to_breadcrumbs("/a/b/c.html"), "../..");
753
754 assert_eq!(to_breadcrumbs("a"), ".");
756 assert_eq!(to_breadcrumbs("a/b"), "..");
757 assert_eq!(to_breadcrumbs("a/b/c"), "../..");
758 }
759
760 #[test]
761 fn convert_latex_markers_simple() {
762 assert_eq!(
763 convert_latex_markers(
764 r#"<span class="math math-inline">\frac{4}{5}</span>"#.to_string()
765 ),
766 r#"<span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mfrac><mn>4</mn><mn>5</mn></mfrac></mrow><annotation encoding="application/x-tex">\frac{4}{5}</annotation></semantics></math></span>"#,
767 );
768 }
769
770 #[test]
771 fn convert_latex_markers_sequence() {
772 assert_eq!(
773 convert_latex_markers(
774 r#"<span class="math math-inline">\frac{4}{5}</span><span class="math math-inline">e^{i \times \pi}</span>"#.to_string()
775 ),
776 r#"<span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mfrac><mn>4</mn><mn>5</mn></mfrac></mrow><annotation encoding="application/x-tex">\frac{4}{5}</annotation></semantics></math></span><span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msup><mi>e</mi><mrow><mi>i</mi><mo>×</mo><mi>π</mi></mrow></msup></mrow><annotation encoding="application/x-tex">e^{i \times \pi}</annotation></semantics></math></span>"#,
777 );
778 }
779}