aiken_project/
docs.rs

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
102/// Generate documentation files for a given project.
103///
104/// The documentation is built using template files located at the root of this crate.
105/// With the documentation, we also build a client-side search index to ease navigation
106/// across multiple modules.
107pub 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, &timestamp);
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        &timestamp,
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    // Section headers
173    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    // Functions
198    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    // Types
230    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    // Constants
242    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    // Module
256    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) // Inline math
303        .output_type(katex::OutputType::Mathml)
304        .build()
305        .unwrap();
306
307    let opts_block = katex::Opts::builder()
308        .display_mode(true) // Block math
309        .output_type(katex::OutputType::Mathml)
310        .build()
311        .unwrap();
312
313    let input = re_inline.replace_all(&input, |caps: &regex::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: &regex::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: &timestamp.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
692// ------ Extra Helpers
693
694fn 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('&', "&amp;")
704            .replace('<', "&lt;")
705            .replace('>', "&gt;")
706            .replace('\"', "&quot;")
707            .replace('\'', "&#39;")
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        // Pages
749        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        // Modules
755        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}