diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b90a5c..2551ec1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ - **aiken-project**: warning on compiler version mismatch. See [de870e2](https://github.com/aiken-lang/aiken/commit/de870e2529eb2336957e228cd30d4850ec2619a2). @rvcas +- **aiken-project**: source links to generated documentation for types, constants and functions. @KtorZ + +- **aiken-project**: comments containing Markdown section headings (`#`, `##`, `###` etc.) will now be preserved and rendered in generated documentation. @KtorZ + +- **aiken-project**: modules starting with `@hidden` in their docs will be skipped from docs generation. @KtorZ + - **uplc**: support evaluation of Plutus V3 transactions, including new purposes introduced in Conway. @KtorZ ### Changed @@ -75,6 +81,10 @@ - **aiken-project**: provide better error (include input ref) when inputs are missing during transaction evaluation. See [#974](https://github.com/aiken-lang/aiken/issues/974). @KtorZ +- **aiken-project**: module inhabitants are no longer alphabetically sorted when generating documentation. Instead, the order in which they are defined in the module is used. @KtorZ + +- **aiken-project**: the sidebar links to modules within a package is now fully hierarchical and (hopefully) better-looking. @KtorZ + ### Removed - **aiken-lang**: clause guards are no longer part of the language. See [#886](https://github.com/aiken-lang/aiken/issues/886). @KtorZ. diff --git a/Cargo.lock b/Cargo.lock index d3d6085b..d3eb9d7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,7 @@ dependencies = [ "askama", "blst", "built", + "camino", "ciborium", "cryptoxide", "dirs", @@ -463,6 +464,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" + [[package]] name = "cc" version = "1.1.8" diff --git a/crates/aiken-project/Cargo.toml b/crates/aiken-project/Cargo.toml index 257bb6a7..eb021e28 100644 --- a/crates/aiken-project/Cargo.toml +++ b/crates/aiken-project/Cargo.toml @@ -16,7 +16,8 @@ build = "build.rs" [dependencies] aiken-lang = { path = "../aiken-lang", version = "1.0.31-alpha" } -askama = "0.12.0" +askama = { version = "0.12.0", features = ["urlencode"] } +camino = "1.1.9" ciborium = "0.2.2" cryptoxide = "0.4.4" dirs = "4.0.0" diff --git a/crates/aiken-project/src/docs.rs b/crates/aiken-project/src/docs.rs index e5c4ff57..767d8ceb 100644 --- a/crates/aiken-project/src/docs.rs +++ b/crates/aiken-project/src/docs.rs @@ -4,10 +4,11 @@ use crate::{ }; use aiken_lang::{ ast::{ - DataType, Definition, Function, ModuleConstant, RecordConstructor, TypeAlias, + DataType, Definition, Function, ModuleConstant, RecordConstructor, Span, TypeAlias, TypedDefinition, }, format, + parser::extra::Comment, tipo::Type, }; use askama::Template; @@ -24,6 +25,9 @@ use std::{ const MAX_COLUMNS: isize = 999; const VERSION: &str = env!("CARGO_PKG_VERSION"); +mod link_tree; +mod source_links; + #[derive(Debug, PartialEq, Eq, Clone)] pub struct DocFile { pub path: PathBuf, @@ -39,9 +43,8 @@ struct ModuleTemplate<'a> { module_name: String, project_name: &'a str, project_version: &'a str, - modules_prefix: String, - modules: &'a Vec, - functions: Vec, + modules: &'a [DocLink], + functions: Vec, types: Vec, constants: Vec, documentation: String, @@ -66,8 +69,7 @@ struct PageTemplate<'a> { page_title: &'a str, project_name: &'a str, project_version: &'a str, - modules_prefix: String, - modules: &'a Vec, + modules: &'a [DocLink], content: String, source: &'a DocLink, timestamp: &'a str, @@ -81,6 +83,7 @@ impl<'a> PageTemplate<'a> { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] struct DocLink { + indent: usize, name: String, path: String, } @@ -89,6 +92,10 @@ 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. @@ -98,10 +105,11 @@ impl DocLink { /// across multiple modules. pub fn generate_all(root: &Path, config: &Config, modules: Vec<&CheckedModule>) -> Vec { let timestamp = new_timestamp(); - let (modules_prefix, modules_links) = generate_modules_links(&modules); + let modules_links = generate_modules_links(&modules); let source = match &config.repository { None => DocLink { + indent: 0, name: String::new(), path: String::new(), }, @@ -110,6 +118,7 @@ pub fn generate_all(root: &Path, config: &Config, modules: Vec<&CheckedModule>) project, platform, }) => DocLink { + indent: 0, name: format!("{user}/{project}"), path: format!("https://{platform}.com/{user}/{project}"), }, @@ -119,13 +128,12 @@ pub fn generate_all(root: &Path, config: &Config, modules: Vec<&CheckedModule>) let mut search_indexes: Vec = vec![]; for module in &modules { - let (indexes, file) = generate_module( - config, - module, - (&modules_prefix, &modules_links), - &source, - ×tamp, - ); + 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); @@ -136,7 +144,7 @@ pub fn generate_all(root: &Path, config: &Config, modules: Vec<&CheckedModule>) output_files.push(generate_readme( root, config, - (&modules_prefix, &modules_links), + &modules_links, &source, ×tamp, )); @@ -145,33 +153,80 @@ pub fn generate_all(root: &Path, config: &Config, modules: Vec<&CheckedModule>) } fn generate_module( + root: &Path, config: &Config, module: &CheckedModule, - (modules_prefix, modules): (&str, &Vec), + 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 = module + let functions: Vec<(Span, DocFunction)> = module .ast .definitions .iter() - .flat_map(DocFunction::from_definition) - .sorted() + .flat_map(|def| DocFunction::from_definition(def, &source_linker)) .collect(); - functions - .iter() - .for_each(|function| search_indexes.push(SearchIndex::from_function(module, function))); + + 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(DocType::from_definition) - .sorted() + .flat_map(|def| DocType::from_definition(def, &source_linker)) .collect(); types .iter() @@ -182,14 +237,13 @@ fn generate_module( .ast .definitions .iter() - .flat_map(DocConstant::from_definition) - .sorted() + .flat_map(|def| DocConstant::from_definition(def, &source_linker)) .collect(); constants .iter() .for_each(|constant| search_indexes.push(SearchIndex::from_constant(module, constant))); - let is_empty = functions.is_empty() && types.is_empty() && constants.is_empty(); + let is_empty = no_functions && types.is_empty() && constants.is_empty(); // Module if !is_empty { @@ -200,13 +254,12 @@ fn generate_module( aiken_version: VERSION, breadcrumbs: to_breadcrumbs(&module.name), documentation: render_markdown(&module.ast.docs.iter().join("\n")), - modules_prefix: modules_prefix.to_string(), 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: functions_and_headers, types, constants, source, @@ -282,7 +335,7 @@ fn generate_static_assets(search_indexes: Vec) -> Vec { fn generate_readme( root: &Path, config: &Config, - (modules_prefix, modules): (&str, &Vec), + modules: &[DocLink], source: &DocLink, timestamp: &Duration, ) -> DocFile { @@ -293,7 +346,6 @@ fn generate_readme( let template = PageTemplate { aiken_version: VERSION, breadcrumbs: ".", - modules_prefix: modules_prefix.to_string(), modules, project_name: &config.name.repo.to_string(), page_title: &config.name.to_string(), @@ -309,46 +361,31 @@ fn generate_readme( } } -fn generate_modules_links(modules: &[&CheckedModule]) -> (String, Vec) { - let non_empty_modules = modules.iter().filter(|module| { - 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, .. }) - ) +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(); - let mut modules_links = vec![]; for module in non_empty_modules { - let module_path = [&module.name.clone(), ".html"].concat(); - modules_links.push(DocLink { - path: module_path, - name: module.name.to_string().clone(), - }); + links.insert(module.name.as_str()); } - modules_links.sort(); - let prefix = if modules_links.len() > 1 { - let prefix = find_modules_prefix(&modules_links); - - for module in &mut modules_links { - let name = module.name.strip_prefix(&prefix).unwrap_or_default(); - module.name = name.strip_prefix('/').unwrap_or(name).to_string(); - if module.name == String::new() { - module.name = "/".to_string() - } - } - - prefix - } else { - String::new() - }; - - (prefix, modules_links) + links.to_vec() } #[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)] @@ -411,7 +448,19 @@ impl SearchIndex { } } -#[derive(PartialEq, Eq, PartialOrd, Ord)] +#[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, @@ -421,26 +470,33 @@ struct DocFunction { } impl DocFunction { - fn from_definition(def: &TypedDefinition) -> Option { + fn from_definition( + def: &TypedDefinition, + source_linker: &source_links::SourceLinker, + ) -> Option<(Span, Self)> { match def { - Definition::Fn(func_def) if func_def.public => Some(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: "#todo".to_string(), - }), + 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, } } @@ -456,7 +512,10 @@ struct DocConstant { } impl DocConstant { - fn from_definition(def: &TypedDefinition) -> Option { + 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(), @@ -469,7 +528,7 @@ impl DocConstant { definition: format::Formatter::new() .docs_const_expr(&const_def.name, &const_def.value) .to_pretty_string(MAX_COLUMNS), - source_url: "#todo".to_string(), + source_url: source_linker.url(const_def.location), }), _ => None, } @@ -489,7 +548,10 @@ struct DocType { } impl DocType { - fn from_definition(def: &TypedDefinition) -> Option { + 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(), @@ -501,7 +563,7 @@ impl DocType { constructors: vec![], parameters: info.parameters.clone(), opaque: false, - source_url: "#todo".to_string(), + source_url: source_linker.url(info.location), }), Definition::DataType(info) if info.public && !info.opaque => Some(DocType { @@ -523,7 +585,7 @@ impl DocType { .collect(), parameters: info.parameters.clone(), opaque: info.opaque, - source_url: "#todo".to_string(), + source_url: source_linker.url(info.location), }), Definition::DataType(info) if info.public && info.opaque => Some(DocType { @@ -536,7 +598,7 @@ impl DocType { constructors: vec![], parameters: info.parameters.clone(), opaque: info.opaque, - source_url: "#todo".to_string(), + source_url: source_linker.url(info.location), }), _ => None, @@ -611,141 +673,6 @@ fn new_timestamp() -> Duration { .expect("get current timestamp") } -fn find_modules_prefix(modules: &[DocLink]) -> String { - do_find_modules_prefix("", modules) -} - -fn do_find_modules_prefix(current_prefix: &str, modules: &[DocLink]) -> String { - let prefix = modules - .iter() - .fold(None, |previous_prefix, module| { - let name = module.name.strip_prefix(current_prefix).unwrap_or_default(); - let name = if name.starts_with('/') { - name.strip_prefix('/').unwrap_or_default() - } else { - name - }; - - let prefix = name.split('/').next().unwrap_or_default().to_string(); - - match previous_prefix { - None if prefix != module.name => Some(prefix), - Some(..) if Some(prefix) == previous_prefix => previous_prefix, - _ => Some(String::new()), - } - }) - .unwrap_or_default(); - - if prefix.is_empty() { - current_prefix.to_string() - } else { - let mut current_prefix = current_prefix.to_owned(); - if !current_prefix.is_empty() { - current_prefix.push('/'); - } - current_prefix.push_str(&prefix); - do_find_modules_prefix(¤t_prefix, modules) - } -} - -#[test] -fn find_modules_prefix_test() { - assert_eq!(find_modules_prefix(&[]), "".to_string()); - - assert_eq!( - find_modules_prefix(&[DocLink { - name: "aiken/list".to_string(), - path: String::new() - }]), - "aiken/list".to_string() - ); - - assert_eq!( - find_modules_prefix(&[DocLink { - name: "my_module".to_string(), - path: String::new() - }]), - "".to_string() - ); - - assert_eq!( - find_modules_prefix(&[ - DocLink { - name: "aiken/list".to_string(), - path: String::new() - }, - DocLink { - name: "aiken/bytearray".to_string(), - path: String::new(), - } - ]), - "aiken".to_string() - ); - - assert_eq!( - find_modules_prefix(&[ - DocLink { - name: "aiken/list".to_string(), - path: String::new() - }, - DocLink { - name: "foo/bytearray".to_string(), - path: String::new(), - } - ]), - "".to_string() - ); -} - -#[test] -fn find_modules_prefix_test_2() { - assert_eq!( - find_modules_prefix(&[ - DocLink { - name: "aiken/trees/bst".to_string(), - path: String::new() - }, - DocLink { - name: "aiken/trees/mt".to_string(), - path: String::new(), - } - ]), - "aiken/trees".to_string() - ); - - assert_eq!( - find_modules_prefix(&[ - DocLink { - name: "aiken/trees/bst".to_string(), - path: String::new() - }, - DocLink { - name: "aiken/trees/mt".to_string(), - path: String::new(), - }, - DocLink { - name: "aiken/sequences".to_string(), - path: String::new(), - } - ]), - "aiken".to_string() - ); - - assert_eq!( - find_modules_prefix(&[ - DocLink { - name: "aiken".to_string(), - path: String::new() - }, - DocLink { - name: "aiken/prelude".to_string(), - path: String::new(), - } - ]), - "".to_string() - ); -} - fn to_breadcrumbs(path: &str) -> String { let breadcrumbs = path .strip_prefix('/') diff --git a/crates/aiken-project/src/docs/link_tree.rs b/crates/aiken-project/src/docs/link_tree.rs new file mode 100644 index 00000000..10306f05 --- /dev/null +++ b/crates/aiken-project/src/docs/link_tree.rs @@ -0,0 +1,506 @@ +use super::DocLink; +use std::{cell::RefCell, rc::Rc}; + +/// A custom tree structure to help constructing the links in the sidebar for documentation. +/// The goal is to end up generating a vector of pre-constructed elements that are simple to handle +/// in the HTML template, but still enforces a visual hierarchy that helps readability of modules. +/// +/// So for example the following: +/// - aiken/cbor +/// - aiken/list +/// - aiken/math +/// - aiken/math/rational +/// - aiken/primitive +/// - aiken/primitive/bytearray +/// - aiken/primitive/integer +/// - cardano/asset +/// - cardano/certificate +/// +/// is nicely turned into: +/// +/// aiken +/// /cbor +/// /list +/// /math +/// /rational +/// /primitive +/// /bytearray +/// /integer +/// +/// cardano +/// /asset +/// /certificate +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug)] +pub(crate) enum LinkTree { + Empty, + + Leaf { + value: String, + }, + + Node { + prefix: String, + separator: bool, + children: Vec>>, + }, +} + +#[allow(clippy::derivable_impls)] +impl Default for LinkTree { + /// The intended way of creating a new empty LinkTree. + fn default() -> LinkTree { + LinkTree::Empty + } +} + +impl LinkTree { + /// Convert a LinkTree into a sequence of DocLinks, ready to be displayed at the right + /// indentation level. + pub fn to_vec(&self) -> Vec { + self.do_to_vec(&[]) + } + + pub fn insert(&mut self, module: &str) { + /// Strip prefix and ensures to remove any leading slash "/" as well. + fn strip_prefix(source: &str, prefix: &str) -> String { + let result = source.strip_prefix(prefix).unwrap(); + if result.starts_with("/") { + result.strip_prefix("/").unwrap().to_string() + } else { + result.to_string() + } + } + + match self { + LinkTree::Empty => { + *self = LinkTree::Leaf { + value: module.to_string(), + } + } + + LinkTree::Leaf { + value: ref mut leaf, + .. + } => { + let (prefix, value) = if let Some(prefix) = common_prefix(module, leaf) { + *leaf = strip_prefix(leaf, &prefix); + let value = strip_prefix(module, &prefix); + (prefix, value) + } else { + (String::new(), module.to_string()) + }; + + // When `prefix == module`, we are in the case where we try to insert a parent + // (e.g. `aiken/math`) into a sub-leaf (e.g. `aiken/math/rational`). So `self` + // must become a child node of our newly created parent. + if prefix == module { + let children = vec![self.clone().into_ref()]; + *self = LinkTree::Node { + // Holds a value, so separator = false + separator: false, + children, + prefix, + }; + // If `leaf.is_empty()`, we are in the case where we are inserting a sub-leaf + // (e.g. `aiken/math/rational`) into a parent (e.g. `aiken/math`); so much that + // we've run out of path segments to follow down. So `self` can turn into a node + // that contains that new leaf. + } else if leaf.is_empty() { + let children = vec![LinkTree::Leaf { value }.into_ref()]; + *self = LinkTree::Node { + // Holds a value, so separator = false + separator: false, + children, + prefix, + }; + // Otherwise, neither one is a child of the other, so we can nest them under a node + // with the corresponding (possibly empty) prefix. + } else { + let mut children = + vec![self.clone().into_ref(), LinkTree::Leaf { value }.into_ref()]; + + children.sort_by(|a, b| a.borrow().path().cmp(b.borrow().path())); + + *self = LinkTree::Node { + // This node is a 'separator' because it doesn't + // hold any value. It is just an intersection point. + separator: true, + children, + prefix, + }; + } + } + + LinkTree::Node { + ref prefix, + ref mut children, + .. + } => { + // When `module.starts_with(prefix)` is true, it means that the module being + // inserted belong to our sub-tree. We do not know *where* exactly though, so we + // have to find whether there's any child that continues the path. If node, we can + // add it to our children. + if module.starts_with(prefix) { + let module = strip_prefix(module, prefix); + + for child in children.iter_mut() { + if common_prefix(child.borrow().path(), module.as_str()).is_some() { + return child.borrow_mut().insert(module.as_str()); + } + } + + children.push( + LinkTree::Leaf { + value: module.to_string(), + } + .into_ref(), + ); + + children.sort_by(|a, b| a.borrow().path().cmp(b.borrow().path())); + // Otherwise, we make it a neighbor that shares no common prefix. + } else { + let mut children = vec![ + self.clone().into_ref(), + LinkTree::Leaf { + value: module.to_string(), + } + .into_ref(), + ]; + + children.sort_by(|a, b| a.borrow().path().cmp(b.borrow().path())); + + *self = LinkTree::Node { + // This node is a 'separator' because it doesn't + // hold any value. It is just an intersection point. + separator: true, + prefix: String::new(), + children, + }; + } + } + } + } + + fn do_to_vec(&self, path: &[&str]) -> Vec { + let mk_path = |value: &str| { + [ + path.join("/").as_str(), + if path.is_empty() { "" } else { "/" }, + value, + ".html", + ] + .concat() + }; + + match self { + LinkTree::Empty => vec![], + + LinkTree::Leaf { value } => { + let last_ix = value.split("/").count(); + let module_path = mk_path(value); + value + .split("/") + .enumerate() + .map(|(offset, segment)| { + if offset == last_ix - 1 { + DocLink { + indent: path.len() + offset, + name: segment.to_string(), + path: module_path.to_string(), + } + } else { + DocLink { + indent: path.len() + offset, + name: segment.to_string(), + path: String::new(), + } + } + }) + .collect::>() + } + + LinkTree::Node { + children, + prefix, + separator, + } => { + let mut links = if prefix.is_empty() { + vec![] + } else { + vec![DocLink { + indent: path.len(), + name: prefix.clone(), + path: if *separator { + String::new() + } else { + mk_path(prefix) + }, + }] + }; + + let mut next = vec![]; + for segment in path { + next.push(*segment); + } + if !prefix.is_empty() { + next.push(prefix); + } + + links.extend( + children + .iter() + .flat_map(|child| child.borrow().do_to_vec(&next[..])), + ); + + links + } + } + } + + fn into_ref(self) -> Rc> { + Rc::new(RefCell::new(self)) + } + + fn path(&self) -> &str { + match self { + LinkTree::Empty => "", + LinkTree::Leaf { ref value, .. } => value.as_str(), + LinkTree::Node { ref prefix, .. } => prefix.as_str(), + } + } +} + +#[test] +fn link_tree_1() { + let mut tree = LinkTree::default(); + tree.insert("foo"); + assert_eq!( + tree.to_vec(), + vec![DocLink { + indent: 0, + name: "foo".to_string(), + path: "foo.html".to_string(), + }] + ) +} + +#[test] +fn link_tree_2() { + let mut tree = LinkTree::default(); + tree.insert("foo"); + tree.insert("bar"); + assert_eq!( + tree.to_vec(), + vec![ + DocLink { + indent: 0, + name: "bar".to_string(), + path: "bar.html".to_string(), + }, + DocLink { + indent: 0, + name: "foo".to_string(), + path: "foo.html".to_string(), + } + ] + ) +} + +#[test] +fn link_tree_3() { + let mut tree = LinkTree::default(); + tree.insert("aiken/list"); + tree.insert("aiken/bytearray"); + assert_eq!( + tree.to_vec(), + vec![ + DocLink { + indent: 0, + name: "aiken".to_string(), + path: String::new(), + }, + DocLink { + indent: 1, + name: "bytearray".to_string(), + path: "aiken/bytearray.html".to_string(), + }, + DocLink { + indent: 1, + name: "list".to_string(), + path: "aiken/list.html".to_string(), + }, + ] + ) +} + +#[test] +fn link_tree_4() { + let mut tree = LinkTree::default(); + tree.insert("aiken/cbor"); + tree.insert("aiken/math/rational"); + tree.insert("aiken/math"); + tree.insert("cardano/foo"); + assert_eq!( + tree.to_vec(), + vec![ + DocLink { + indent: 0, + name: "aiken".to_string(), + path: String::new(), + }, + DocLink { + indent: 1, + name: "cbor".to_string(), + path: "aiken/cbor.html".to_string(), + }, + DocLink { + indent: 1, + name: "math".to_string(), + path: "aiken/math.html".to_string(), + }, + DocLink { + indent: 2, + name: "rational".to_string(), + path: "aiken/math/rational.html".to_string(), + }, + DocLink { + indent: 0, + name: "cardano/foo".to_string(), + path: "cardano/foo.html".to_string(), + } + ] + ) +} + +#[test] +fn link_tree_5() { + let mut tree = LinkTree::default(); + tree.insert("cardano/foo"); + tree.insert("cardano"); + tree.insert("aiken/cbor"); + tree.insert("aiken/math"); + tree.insert("aiken/math/rational"); + assert_eq!( + tree.to_vec(), + vec![ + DocLink { + indent: 0, + name: "aiken".to_string(), + path: String::new(), + }, + DocLink { + indent: 1, + name: "cbor".to_string(), + path: "aiken/cbor.html".to_string(), + }, + DocLink { + indent: 1, + name: "math".to_string(), + path: "aiken/math.html".to_string(), + }, + DocLink { + indent: 2, + name: "rational".to_string(), + path: "aiken/math/rational.html".to_string(), + }, + DocLink { + indent: 0, + name: "cardano".to_string(), + path: "cardano.html".to_string(), + }, + DocLink { + indent: 1, + name: "foo".to_string(), + path: "cardano/foo.html".to_string(), + } + ] + ) +} + +/// Find the common module prefix between two module path, if any. +/// +/// ```rust +/// assert_eq!( +/// common_prefix("foo", "foo"), +/// Some("foo".to_string()), +/// ) +/// +/// assert_eq!( +/// common_prefix("aiken/list", "aiken/bytearray"), +/// Some("aiken".to_string()), +/// ) +/// +/// assert_eq!( +/// common_prefix("aiken/list", "cardano/asset"), +/// None, +/// ) +/// ``` +pub fn common_prefix(left: &str, right: &str) -> Option { + let mut prefix = vec![]; + + for (left, right) in left.split('/').zip(right.split('/')) { + if !left.is_empty() && left == right { + prefix.push(left); + } else { + break; + } + } + + if prefix.is_empty() { + None + } else { + Some(prefix.join("/")) + } +} + +#[test] +fn common_prefix_1() { + assert_eq!(common_prefix("", ""), None) +} + +#[test] +fn common_prefix_2() { + assert_eq!(common_prefix("foo", "bar"), None) +} + +#[test] +fn common_prefix_3() { + assert_eq!(common_prefix("foo", "foo"), Some("foo".to_string())) +} + +#[test] +fn common_prefix_4() { + assert_eq!(common_prefix("foo", ""), None) +} + +#[test] +fn common_prefix_5() { + assert_eq!( + common_prefix("foo/bar", "foo/bar"), + Some("foo/bar".to_string()) + ) +} + +#[test] +fn common_prefix_6() { + assert_eq!( + common_prefix("foo/bar", "foo/bar/baz"), + Some("foo/bar".to_string()) + ) +} + +#[test] +fn common_prefix_7() { + assert_eq!( + common_prefix("foo/bar", "foo/wow/baz"), + Some("foo".to_string()) + ) +} + +#[test] +fn common_prefix_8() { + assert_eq!( + common_prefix("foo/bar/baz", "foo/wow/baz"), + Some("foo".to_string()) + ) +} diff --git a/crates/aiken-project/src/docs/source_links.rs b/crates/aiken-project/src/docs/source_links.rs new file mode 100644 index 00000000..092539ec --- /dev/null +++ b/crates/aiken-project/src/docs/source_links.rs @@ -0,0 +1,84 @@ +use crate::{ + config::{Config, Platform}, + CheckedModule, +}; +use aiken_lang::{ast::Span, line_numbers::LineNumbers}; +use camino::{Utf8Component, Utf8Path}; +use std::path::Path; + +pub struct SourceLinker { + line_numbers: LineNumbers, + url_pattern: Option<(String, String)>, +} + +impl SourceLinker { + pub fn new(root: &Path, config: &Config, module: &CheckedModule) -> Self { + let utf8_path = <&Utf8Path>::try_from( + module + .input_path + .as_path() + .strip_prefix(root) + .expect("root path isn't a prefix of project modules' paths!"), + ) + .expect("module path contains non UTF-8 characters!"); + + let path_in_repo = to_url_path(utf8_path).unwrap_or_default(); + + let url_pattern = config + .repository + .as_ref() + .map(|repository| match repository.platform { + Platform::Github => ( + format!( + "https://github.com/{}/{}/blob/{}/{}#L", + repository.user, repository.project, config.version, path_in_repo + ), + "-L".into(), + ), + Platform::Gitlab => ( + format!( + "https://gitlab.com/{}/{}/-/blob/{}/{}#L", + repository.user, repository.project, config.version, path_in_repo + ), + "-".into(), + ), + Platform::Bitbucket => ( + format!( + "https://bitbucket.com/{}/{}/src/{}/{}#lines-", + repository.user, repository.project, config.version, path_in_repo + ), + ":".into(), + ), + }); + + SourceLinker { + line_numbers: LineNumbers::new(&module.code), + url_pattern, + } + } + + pub fn url(&self, span: Span) -> String { + match &self.url_pattern { + Some((base, line_sep)) => { + let start_line = self.line_numbers.line_number(span.start).unwrap(); + let end_line = self.line_numbers.line_number(span.end).unwrap(); + format!("{base}{start_line}{line_sep}{end_line}") + } + None => "".into(), + } + } +} + +fn to_url_path(path: &Utf8Path) -> Option { + let mut buf = String::new(); + for c in path.components() { + if let Utf8Component::Normal(s) = c { + buf.push_str(s); + } + buf.push('/'); + } + + let _ = buf.pop(); + + Some(buf) +} diff --git a/crates/aiken-project/src/module.rs b/crates/aiken-project/src/module.rs index f6a3b4e2..cb28ff5c 100644 --- a/crates/aiken-project/src/module.rs +++ b/crates/aiken-project/src/module.rs @@ -291,6 +291,15 @@ pub struct CheckedModule { } impl CheckedModule { + pub fn skip_doc_generation(&self) -> bool { + self.ast + .docs + .first() + .map(|s| s.as_str().trim()) + .unwrap_or_default() + == "@hidden" + } + pub fn to_cbor(&self) -> Vec { let mut module_bytes = vec![]; diff --git a/crates/aiken-project/src/telemetry.rs b/crates/aiken-project/src/telemetry.rs index 2b7da6de..3c7fe883 100644 --- a/crates/aiken-project/src/telemetry.rs +++ b/crates/aiken-project/src/telemetry.rs @@ -97,10 +97,11 @@ impl EventListener for Terminal { root, } => { eprintln!( - "{} {} {} ({})", - " Generating documentation" + "{} {} for {} {} ({})", + " Generating" .if_supports_color(Stderr, |s| s.bold()) .if_supports_color(Stderr, |s| s.purple()), + "documentation".if_supports_color(Stderr, |s| s.bold()), name.if_supports_color(Stderr, |s| s.bold()), version, root.to_str() @@ -140,10 +141,11 @@ impl EventListener for Terminal { } Event::GeneratingDocFiles { output_path } => { eprintln!( - "{} in {}", - " Generating documentation files" + "{} {} to {}", + " Writing" .if_supports_color(Stderr, |s| s.bold()) .if_supports_color(Stderr, |s| s.purple()), + "documentation files".if_supports_color(Stderr, |s| s.bold()), output_path .to_str() .unwrap_or("") diff --git a/crates/aiken-project/src/test_framework.rs b/crates/aiken-project/src/test_framework.rs index 60e005c4..935b931e 100644 --- a/crates/aiken-project/src/test_framework.rs +++ b/crates/aiken-project/src/test_framework.rs @@ -495,7 +495,7 @@ impl Prng { /// Obtain a Prng back from a fuzzer execution. As a reminder, fuzzers have the following /// signature: /// - /// type Fuzzer = fn(Prng) -> Option<(Prng, a)> + /// `type Fuzzer = fn(Prng) -> Option<(Prng, a)>` /// /// In nominal scenarios (i.e. when the fuzzer is made from a seed and evolve pseudo-randomly), /// it cannot yield 'None'. When replayed however, we can't easily guarantee that the changes @@ -507,8 +507,10 @@ impl Prng { fn as_prng(cst: &PlutusData) -> Prng { if let PlutusData::Constr(Constr { tag, fields, .. }) = cst { if *tag == 121 + Prng::SEEDED { - if let [PlutusData::BoundedBytes(bytes), PlutusData::BoundedBytes(choices)] = - &fields[..] + if let [ + PlutusData::BoundedBytes(bytes), + PlutusData::BoundedBytes(choices), + ] = &fields[..] { return Prng::Seeded { choices: choices.to_vec(), @@ -1087,9 +1089,11 @@ impl TryFrom for Assertion { final_else, .. } => { - if let [IfBranch { - condition, body, .. - }] = &branches[..] + if let [ + IfBranch { + condition, body, .. + }, + ] = &branches[..] { let then_is_true = match body { TypedExpr::Var { @@ -1509,13 +1513,14 @@ mod test { } "#}); - assert!(prop - .run::<()>( + assert!( + prop.run::<()>( 42, PropertyTest::DEFAULT_MAX_SUCCESS, &PlutusVersion::default() ) - .is_success()); + .is_success() + ); } #[test] diff --git a/crates/aiken-project/templates/_layout.html b/crates/aiken-project/templates/_layout.html index 16174afa..ae91bcc3 100644 --- a/crates/aiken-project/templates/_layout.html +++ b/crates/aiken-project/templates/_layout.html @@ -186,23 +186,26 @@ {% endif %} + {% block sidebar_content %}{% endblock %} +

Modules

- {% if !modules_prefix.is_empty() %} -

{{ modules_prefix }}/

- {% endif %}
- - {% block sidebar_content %}{% endblock %}
@@ -217,6 +220,8 @@ + + @@ -276,9 +281,35 @@ el.prepend(a); }); + + + diff --git a/crates/aiken-project/templates/css/index.css b/crates/aiken-project/templates/css/index.css index 347f89fb..ec987959 100644 --- a/crates/aiken-project/templates/css/index.css +++ b/crates/aiken-project/templates/css/index.css @@ -5,7 +5,7 @@ --search-width: 680px; --header-height: 60px; --hash-offset: calc(var(--header-height) * 1.67); - --sidebar-width: 240px; + --sidebar-width: 260px; --gap: 24px; --small-gap: calc(var(--gap) / 2); --tiny-gap: calc(var(--small-gap) / 2); @@ -60,6 +60,15 @@ html { scroll-padding-top: var(--hash-offset); } +.tippy-box { + background-color: var(--color-text); + color: var(--color-background); +} + +.tippy-arrow { + color: var(--color-text); +} + a, a:visited { color: var(--color-link); @@ -281,11 +290,13 @@ p code { /* Module doc */ .module-name > a, +.module-heading > a, .module-member-kind > a { color: inherit; } .module-name > a:hover, +.module-heading > a:hover, .module-member-kind > a:hover { text-decoration: none; } @@ -303,10 +314,7 @@ p code { font-size: 0.95rem; max-height: calc(100vh - var(--header-height)); overflow-y: auto; - overscroll-behavior: contain; - padding-top: var(--gap); - padding-bottom: var(--gap); - padding-left: var(--gap); + padding: var(--gap) var(--small-gap); position: fixed; top: var(--header-height); transition: transform 0.5s ease; @@ -315,11 +323,14 @@ p code { height: 100%; display: flex; flex-direction: column; + overflow-x: hidden; + word-break: normal; } .sidebar h2 { margin: 0; color: var(--color-link-accent); + font-size: 1.75em; } .sidebar h3.modules-prefix { @@ -340,17 +351,9 @@ p code { .sidebar li { line-height: 1.2; margin-bottom: 4px; -} - -.sidebar ul li a > strong { - font-weight: 900; - color: var(--color-link); -} - -.sidebar ul li a > strong::before { - font-size: 0.75em; - content: 'ᐅ '; - padding-right: 0.1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .sidebar .sidebar-toggle { @@ -407,6 +410,10 @@ body.drawer-open .label-closed { .module-member-kind { font-size: 2rem; +} + +.module-heading, +.module-member-kind { color: var(--color-text); } @@ -457,6 +464,22 @@ body.drawer-open .label-closed { margin: 0 0 0 var(--small-gap); } +a.member-source, +a.member-source:hover, +a.member-source:visited { + text-decoration: none; + color: var(--color-text-accent); +} + +a.member-source::before { + content: '{ ... }'; + transition: all 0.5s ease-out; +} + +a.member-source:hover::before { + content: '{ view source }'; +} + /* Custom type constructors */ .constructor-list { @@ -1059,3 +1082,83 @@ body.theme-dark { padding-top: 0; } } + +.sidebar li[data-indent] { + margin-bottom: unset; + display: flex; + flex-direction: row; + color: var(--color-background-accent); +} + +.sidebar li[data-indent] span, +.sidebar li[data-indent] a { + display: flex; + width: 100%; + position: relative; + left: -0.5rem; + padding-left: 0.75rem; +} + +.sidebar li[data-indent] a:hover, +.sidebar li[data-current] a, +.sidebar li[data-current] a:visited { + padding-right: 0.25rem; + text-decoration: none; + color: var(--color-text-accent); + background: + linear-gradient( + 115deg, + transparent 0.5rem, + var(--color-background-accent) 0.5rem + ); +} + +.sidebar li[data-indent="0"] { + font-size: 1.1em; +} + +.sidebar li[data-indent="0"]:not(first-child) { + margin-top: 0.5rem; +} + +.sidebar li[data-indent]::before { + content: '/'; + font-size: 1.05em; + font-family: monospace; + padding-left: 0.25rem; + letter-spacing: -0.1rem; + padding-left: 4rem; + display: flex; + color: var(--color-background-accent); +} + +.sidebar li[data-indent="0"]::before { display: none; } +.sidebar li[data-indent="1"]::before { padding-left: 1rem; } +.sidebar li[data-indent="2"]::before { padding-left: 2rem; } +.sidebar li[data-indent="3"]::before { padding-left: 3rem; } + +.sidebar li[data-heading] { + font-weight: bold; + font-size: 1em; + color: var(--color-link-accent); +} + +.sidebar li[data-heading="1"] { + margin-top: 1.5rem; + font-size: 1.4em; +} + +.sidebar li[data-heading="2"] { + margin-top: 1rem; + font-size: 1.2em; +} + +.sidebar li[data-heading="3"] { + margin-top: 0.5rem; + font-size: 1.1em; +} + +.sidebar li[data-heading]:first-child, +.sidebar li[data-heading] + li[data-heading] { + margin-top: 0; +} diff --git a/crates/aiken-project/templates/module.html b/crates/aiken-project/templates/module.html index 8a695650..4e087251 100644 --- a/crates/aiken-project/templates/module.html +++ b/crates/aiken-project/templates/module.html @@ -22,8 +22,14 @@ {% if !functions.is_empty() %}

Functions

    - {% for function in functions %} -
  • {{ function.name }}
  • + {% for function_or_section in functions %} + {% match function_or_section %} + {% when Interspersed::Function with (function) %} +
  • {{ function.name }}
  • + + {% when Interspersed::Section with (section) %} +
  • {{ section.title }}
  • + {% endmatch %} {% endfor %}
{% endif %} @@ -50,11 +56,7 @@ {% if !type_info.source_url.is_empty() %} - + {% endif %}
@@ -100,11 +102,7 @@

{{ constant.definition }}

{% if !constant.source_url.is_empty() %} - + {% endif %}
{{ constant.documentation|safe }}
@@ -118,20 +116,29 @@

Functions

- {% for function in functions %} -
-
-

{{ function.signature }}

- {% if !function.source_url.is_empty() %} - + {% for function_or_section in functions %} + {% match function_or_section %} + {% when Interspersed::Function with (function) %} +
+
+

{{ function.signature }}

+ {% if !function.source_url.is_empty() %} + + {% endif %} +
+
{{ function.documentation|safe }}
+
+ {% when Interspersed::Section with (section) %} + {% if section.heading == 1 %} +

{{ section.title }}

+ {% else if section.heading == 2 %} +

{{ section.title }}

+ {% else if section.heading == 3 %} +

{{ section.title }}

+ {% else %} +

{{ section.title }}

{% endif %} -
-
{{ function.documentation|safe }}
-
+ {% endmatch %} {% endfor %} {% endif %} diff --git a/examples/acceptance_tests/107/aiken.lock b/examples/acceptance_tests/107/aiken.lock new file mode 100644 index 00000000..6e350cda --- /dev/null +++ b/examples/acceptance_tests/107/aiken.lock @@ -0,0 +1,7 @@ +# This file was generated by Aiken +# You typically do not need to edit this file + +requirements = [] +packages = [] + +[etags] diff --git a/examples/acceptance_tests/107/aiken.toml b/examples/acceptance_tests/107/aiken.toml new file mode 100644 index 00000000..37a52681 --- /dev/null +++ b/examples/acceptance_tests/107/aiken.toml @@ -0,0 +1,9 @@ +name = "aiken-lang/107" +version = "0.0.0" +license = "Apache-2.0" +description = "Aiken contracts for project 'aiken-lang/107'" + +[repository] +user = "aiken-lang" +project = "107" +platform = "github" diff --git a/examples/acceptance_tests/107/lib/tests.ak b/examples/acceptance_tests/107/lib/tests.ak new file mode 100644 index 00000000..431a5c2b --- /dev/null +++ b/examples/acceptance_tests/107/lib/tests.ak @@ -0,0 +1,7 @@ +fn new_list() { + [] +} + +test foo() { + fn() { new_list }()() == [] +}