use crate::{ config::{ProjectConfig, Repository}, module::CheckedModule, }; use aiken_lang::{ ast::{ DataType, Definition, Function, ModuleConstant, RecordConstructor, Span, TypeAlias, TypedDefinition, }, format, parser::extra::Comment, tipo::Type, }; use askama::Template; use itertools::Itertools; use pulldown_cmark as markdown; use regex::Regex; use serde::Serialize; use serde_json as json; use std::{ path::{Path, PathBuf}, rc::Rc, time::{Duration, SystemTime}, }; const MAX_COLUMNS: isize = 80; const VERSION: &str = env!("CARGO_PKG_VERSION"); pub mod link_tree; pub mod source_links; #[derive(Debug, PartialEq, Eq, Clone)] pub struct DocFile { pub path: PathBuf, pub content: String, } #[derive(Template)] #[template(path = "module.html")] struct ModuleTemplate<'a> { aiken_version: &'a str, breadcrumbs: String, page_title: &'a str, module_name: String, project_name: &'a str, project_version: &'a str, modules: &'a [DocLink], functions: Vec, types: Vec, constants: Vec, documentation: String, source: &'a DocLink, timestamp: String, } impl ModuleTemplate<'_> { pub fn is_current_module(&self, module: &DocLink) -> bool { match module.path.split(".html").next() { None => false, Some(name) => self.module_name == name, } } } #[derive(Template)] #[template(path = "page.html")] struct PageTemplate<'a> { aiken_version: &'a str, breadcrumbs: &'a str, page_title: &'a str, project_name: &'a str, project_version: &'a str, modules: &'a [DocLink], content: String, source: &'a DocLink, timestamp: &'a str, } impl PageTemplate<'_> { pub fn is_current_module(&self, _module: &DocLink) -> bool { false } } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] pub struct DocLink { indent: usize, name: String, path: String, } impl DocLink { pub fn is_empty(&self) -> bool { self.name.is_empty() } pub fn is_separator(&self) -> bool { self.path.is_empty() } } /// Generate documentation files for a given project. /// /// The documentation is built using template files located at the root of this crate. /// With the documentation, we also build a client-side search index to ease navigation /// across multiple modules. pub fn generate_all( root: &Path, config: &ProjectConfig, modules: Vec<&CheckedModule>, ) -> Vec { let timestamp = new_timestamp(); let modules_links = generate_modules_links(&modules); let source = match &config.repository { None => DocLink { indent: 0, name: String::new(), path: String::new(), }, Some(Repository { user, project, platform, }) => DocLink { indent: 0, name: format!("{user}/{project}"), path: format!("https://{platform}.com/{user}/{project}"), }, }; let mut output_files: Vec = vec![]; let mut search_indexes: Vec = vec![]; for module in &modules { if module.skip_doc_generation() { continue; } let (indexes, file) = generate_module(root, config, module, &modules_links, &source, ×tamp); if !indexes.is_empty() { search_indexes.extend(indexes); output_files.push(file); } } output_files.extend(generate_static_assets(search_indexes)); output_files.push(generate_readme( root, config, &modules_links, &source, ×tamp, )); output_files } fn generate_module( root: &Path, config: &ProjectConfig, module: &CheckedModule, modules: &[DocLink], source: &DocLink, timestamp: &Duration, ) -> (Vec, DocFile) { let mut search_indexes = vec![]; let source_linker = source_links::SourceLinker::new(root, config, module); // Section headers let mut section_headers = module .extra .comments .iter() .filter_map(|span| { let comment = Comment::from((span, module.code.as_str())) .content .trim_start(); if comment.starts_with('#') { let trimmed = comment.trim_start_matches('#'); let heading = comment.len() - trimmed.len(); Some(( span, DocSection { heading, title: trimmed.trim_start().to_string(), }, )) } else { None } }) .collect_vec(); // Functions let functions: Vec<(Span, DocFunction)> = module .ast .definitions .iter() .flat_map(|def| DocFunction::from_definition(def, &source_linker)) .collect(); functions.iter().for_each(|(_, function)| { search_indexes.push(SearchIndex::from_function(module, function)) }); let no_functions = functions.is_empty(); let mut functions_and_headers = Vec::new(); for (span_fn, function) in functions { let mut to_remove = vec![]; for (ix, (span_h, header)) in section_headers.iter().enumerate() { if span_h.start < span_fn.start { functions_and_headers.push(Interspersed::Section(header.clone())); to_remove.push(ix); } } for ix in to_remove.iter().rev() { section_headers.remove(*ix); } functions_and_headers.push(Interspersed::Function(function)) } // Types let types: Vec = module .ast .definitions .iter() .flat_map(|def| DocType::from_definition(def, &source_linker)) .sorted() .collect(); types .iter() .for_each(|type_info| search_indexes.push(SearchIndex::from_type(module, type_info))); // Constants let constants: Vec = module .ast .definitions .iter() .flat_map(|def| DocConstant::from_definition(def, &source_linker)) .sorted() .collect(); constants .iter() .for_each(|constant| search_indexes.push(SearchIndex::from_constant(module, constant))); let is_empty = no_functions && types.is_empty() && constants.is_empty(); // Module if !is_empty { search_indexes.push(SearchIndex::from_module(module)); } let module = ModuleTemplate { aiken_version: VERSION, breadcrumbs: to_breadcrumbs(&module.name), documentation: render_markdown(&module.ast.docs.iter().join("\n")), modules, project_name: &config.name.repo.to_string(), page_title: &format!("{} - {}", module.name, config.name), module_name: module.name.clone(), project_version: &config.version.to_string(), functions: functions_and_headers, types, constants, source, timestamp: timestamp.as_secs().to_string(), }; let rendered_content = convert_latex_markers( module .render() .expect("Module documentation template rendering"), ); ( search_indexes, DocFile { path: PathBuf::from(format!("{}.html", module.module_name)), content: rendered_content, }, ) } #[cfg(windows)] fn convert_latex_markers(input: String) -> String { input } #[cfg(not(windows))] fn convert_latex_markers(input: String) -> String { let re_inline = Regex::new(r#"\s*(.+?)\s*"#).unwrap(); let re_block = Regex::new(r#"\s*(.+?)\s*"#).unwrap(); let opts_inline = katex::Opts::builder() .display_mode(false) // Inline math .output_type(katex::OutputType::Mathml) .build() .unwrap(); let opts_block = katex::Opts::builder() .display_mode(true) // Block math .output_type(katex::OutputType::Mathml) .build() .unwrap(); let input = re_inline.replace_all(&input, |caps: ®ex::Captures| { let formula = &caps[1]; katex::render_with_opts(formula, &opts_inline).unwrap_or_else(|_| formula.to_string()) }); re_block .replace_all(&input, |caps: ®ex::Captures| { let formula = &caps[1]; katex::render_with_opts(formula, &opts_block).unwrap_or_else(|_| formula.to_string()) }) .to_string() } fn generate_static_assets(search_indexes: Vec) -> Vec { let mut assets: Vec = vec![]; assets.push(DocFile { path: PathBuf::from("favicon.svg"), content: std::include_str!("../templates/favicon.svg").to_string(), }); assets.push(DocFile { path: PathBuf::from("css/atom-one-light.min.css"), content: std::include_str!("../templates/css/atom-one-light.min.css").to_string(), }); assets.push(DocFile { path: PathBuf::from("css/atom-one-dark.min.css"), content: std::include_str!("../templates/css/atom-one-dark.min.css").to_string(), }); assets.push(DocFile { path: PathBuf::from("css/index.css"), content: std::include_str!("../templates/css/index.css").to_string(), }); assets.push(DocFile { path: PathBuf::from("js/highlight.min.js"), content: std::include_str!("../templates/js/highlight.min.js").to_string(), }); assets.push(DocFile { path: PathBuf::from("js/highlightjs-aiken.js"), content: std::include_str!("../templates/js/highlightjs-aiken.js").to_string(), }); assets.push(DocFile { path: PathBuf::from("js/lunr.min.js"), content: std::include_str!("../templates/js/lunr.min.js").to_string(), }); assets.push(DocFile { path: PathBuf::from("js/index.js"), content: std::include_str!("../templates/js/index.js").to_string(), }); assets.push(DocFile { path: PathBuf::from("search-data.js"), content: format!( "window.Aiken.initSearch({});", json::to_string(&escape_html_contents(search_indexes)) .expect("search index serialization") ), }); assets } fn generate_readme( root: &Path, config: &ProjectConfig, modules: &[DocLink], source: &DocLink, timestamp: &Duration, ) -> DocFile { let path = PathBuf::from("index.html"); let content = std::fs::read_to_string(root.join("README.md")).unwrap_or_default(); let template = PageTemplate { aiken_version: VERSION, breadcrumbs: ".", modules, project_name: &config.name.repo.to_string(), page_title: &config.name.to_string(), project_version: &config.version.to_string(), content: render_markdown(&content), source, timestamp: ×tamp.as_secs().to_string(), }; DocFile { path, content: template.render().expect("Page template rendering"), } } fn generate_modules_links(modules: &[&CheckedModule]) -> Vec { let non_empty_modules = modules .iter() .filter(|module| { !module.skip_doc_generation() && module.ast.definitions.iter().any(|def| { matches!( def, Definition::Fn(Function { public: true, .. }) | Definition::DataType(DataType { public: true, .. }) | Definition::TypeAlias(TypeAlias { public: true, .. }) | Definition::ModuleConstant(ModuleConstant { public: true, .. }) ) }) }) .sorted_by(|a, b| a.name.cmp(&b.name)) .collect_vec(); let mut links = link_tree::LinkTree::default(); for module in non_empty_modules { links.insert(module.name.as_str()); } links.to_vec() } #[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)] struct SearchIndex { doc: String, title: String, content: String, url: String, } impl SearchIndex { fn from_function(module: &CheckedModule, function: &DocFunction) -> Self { SearchIndex { doc: module.name.to_string(), title: function.name.to_string(), content: format!("{}\n{}", function.signature, function.raw_documentation), url: format!("{}.html#{}", module.name, function.name), } } fn from_type(module: &CheckedModule, type_info: &DocType) -> Self { let constructors = type_info .constructors .iter() .map(|constructor| { format!( "{}\n{}", constructor.definition, constructor.raw_documentation ) }) .join("\n"); SearchIndex { doc: module.name.to_string(), title: type_info.name.to_string(), content: format!( "{}\n{}\n{}", type_info.definition, type_info.raw_documentation, constructors, ), url: format!("{}.html#{}", module.name, type_info.name), } } fn from_constant(module: &CheckedModule, constant: &DocConstant) -> Self { SearchIndex { doc: module.name.to_string(), title: constant.name.to_string(), content: format!("{}\n{}", constant.definition, constant.raw_documentation), url: format!("{}.html#{}", module.name, constant.name), } } fn from_module(module: &CheckedModule) -> Self { SearchIndex { doc: module.name.to_string(), title: module.name.to_string(), content: module.ast.docs.iter().join("\n"), url: format!("{}.html", module.name), } } } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] enum Interspersed { Section(DocSection), Function(DocFunction), } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] struct DocSection { heading: usize, title: String, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] struct DocFunction { name: String, signature: String, documentation: String, raw_documentation: String, source_url: String, } impl DocFunction { fn from_definition( def: &TypedDefinition, source_linker: &source_links::SourceLinker, ) -> Option<(Span, Self)> { match def { Definition::Fn(func_def) if func_def.public => Some(( func_def.location, DocFunction { name: func_def.name.clone(), documentation: func_def .doc .as_deref() .map(render_markdown) .unwrap_or_default(), raw_documentation: func_def.doc.as_deref().unwrap_or_default().to_string(), signature: format::Formatter::new() .docs_fn_signature( &func_def.name, &func_def.arguments, &func_def.return_annotation, func_def.return_type.clone(), ) .to_pretty_string(MAX_COLUMNS), source_url: source_linker .url(func_def.location.map_end(|_| func_def.end_position)), }, )), _ => None, } } } #[derive(PartialEq, Eq, PartialOrd, Ord)] struct DocConstant { name: String, definition: String, documentation: String, raw_documentation: String, source_url: String, } impl DocConstant { fn from_definition( def: &TypedDefinition, source_linker: &source_links::SourceLinker, ) -> Option { match def { Definition::ModuleConstant(const_def) if const_def.public => Some(DocConstant { name: const_def.name.clone(), documentation: const_def .doc .as_deref() .map(render_markdown) .unwrap_or_default(), raw_documentation: const_def.doc.as_deref().unwrap_or_default().to_string(), definition: format::Formatter::new() .docs_const_expr(&const_def.name, &const_def.value) .to_pretty_string(MAX_COLUMNS), source_url: source_linker.url(const_def.location), }), _ => None, } } } #[derive(PartialEq, Eq, PartialOrd, Ord, Debug)] struct DocType { name: String, definition: String, documentation: String, raw_documentation: String, constructors: Vec, parameters: Vec, opaque: bool, source_url: String, } impl DocType { fn from_definition( def: &TypedDefinition, source_linker: &source_links::SourceLinker, ) -> Option { match def { Definition::TypeAlias(info) if info.public => Some(DocType { name: info.alias.clone(), definition: format::Formatter::new() .docs_type_alias(&info.alias, &info.parameters, &info.annotation) .to_pretty_string(MAX_COLUMNS), documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(), raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(), constructors: vec![], parameters: info.parameters.clone(), opaque: false, source_url: source_linker.url(info.location), }), Definition::DataType(info) if info.public && !info.opaque => Some(DocType { name: info.name.clone(), definition: format::Formatter::new() .docs_data_type( &info.name, &info.parameters, &info.constructors, &info.location, ) .to_pretty_string(MAX_COLUMNS), documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(), raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(), constructors: info .constructors .iter() .map(DocTypeConstructor::from_record_constructor) .collect(), parameters: info.parameters.clone(), opaque: info.opaque, source_url: source_linker.url(info.location), }), Definition::DataType(info) if info.public && info.opaque => Some(DocType { name: info.name.clone(), definition: format::Formatter::new() .docs_opaque_data_type(&info.name, &info.parameters, &info.location) .to_pretty_string(MAX_COLUMNS), documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(), raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(), constructors: vec![], parameters: info.parameters.clone(), opaque: info.opaque, source_url: source_linker.url(info.location), }), _ => None, } } } #[derive(PartialEq, Eq, PartialOrd, Ord, Debug)] struct DocTypeConstructor { definition: String, documentation: String, raw_documentation: String, } impl DocTypeConstructor { fn from_record_constructor(constructor: &RecordConstructor>) -> Self { let doc_args = constructor .arguments .iter() .filter_map(|arg| match (arg.label.as_deref(), arg.doc.as_deref()) { (Some(label), Some(doc)) => Some(format!("#### `.{label}`\n{doc}\n
\n",)), _ => None, }) .join("\n"); DocTypeConstructor { definition: format::Formatter::new() .docs_record_constructor(constructor) .to_pretty_string(format::MAX_COLUMNS), documentation: constructor .doc .as_deref() .map(|doc| render_markdown(&format!("{doc}\n{doc_args}"))) .or(if doc_args.is_empty() { None } else { Some(render_markdown(&format!("\n{doc_args}"))) }) .unwrap_or_default(), raw_documentation: constructor.doc.as_deref().unwrap_or_default().to_string(), } } } // ------ Extra Helpers fn render_markdown(text: &str) -> String { let mut s = String::with_capacity(text.len() * 3 / 2); let p = markdown::Parser::new_ext(text, markdown::Options::all()); markdown::html::push_html(&mut s, p); s } fn escape_html_contents(indexes: Vec) -> Vec { fn escape_html_content(it: String) -> String { it.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('\"', """) .replace('\'', "'") } indexes .into_iter() .map(|idx| SearchIndex { doc: idx.doc, title: idx.title, content: escape_html_content(idx.content), url: idx.url, }) .collect::>() } fn new_timestamp() -> Duration { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("get current timestamp") } fn to_breadcrumbs(path: &str) -> String { let breadcrumbs = path .strip_prefix('/') .unwrap_or(path) .split('/') .skip(1) .map(|_| "..") .join("/"); if breadcrumbs.is_empty() { ".".to_string() } else { breadcrumbs } } #[cfg(test)] mod tests { use super::*; #[test] fn to_breadcrumbs_test() { // Pages assert_eq!(to_breadcrumbs("a.html"), "."); assert_eq!(to_breadcrumbs("/a.html"), "."); assert_eq!(to_breadcrumbs("/a/b.html"), ".."); assert_eq!(to_breadcrumbs("/a/b/c.html"), "../.."); // Modules assert_eq!(to_breadcrumbs("a"), "."); assert_eq!(to_breadcrumbs("a/b"), ".."); assert_eq!(to_breadcrumbs("a/b/c"), "../.."); } #[test] fn convert_latex_markers_simple() { assert_eq!( convert_latex_markers( r#"\frac{4}{5}"#.to_string() ), r#"45\frac{4}{5}"#, ); } #[test] fn convert_latex_markers_sequence() { assert_eq!( convert_latex_markers( r#"\frac{4}{5}e^{i \times \pi}"#.to_string() ), r#"45\frac{4}{5}ei×πe^{i \times \pi}"#, ); } }