diff --git a/.gitignore b/.gitignore index f87106db05..524c94e669 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ Cargo.lock **/doc assets/scripts/tlconfig.lua **.log -**build/ \ No newline at end of file +**build/ +.html \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 33b44ca151..3cae84ef27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,7 +89,7 @@ members = [ "crates/xtask", "crates/script_integration_test_harness", "crates/bevy_mod_scripting_derive", - "crates/ladfile", + "crates/ladfile", "crates/lad_backends/mdbook_lad_preprocessor", ] resolver = "2" exclude = ["crates/bevy_api_gen", "crates/macro_tests"] diff --git a/crates/lad_backends/mdbook_lad_preprocessor/Cargo.toml b/crates/lad_backends/mdbook_lad_preprocessor/Cargo.toml new file mode 100644 index 0000000000..8aa3f1c1f1 --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "mdbook_lad_preprocessor" +version = "0.1.0" +edition = "2021" +authors = ["Maksymilian Mozolewski "] +license = "MIT OR Apache-2.0" +description = "Language Agnostic Declaration (LAD) file format for the bevy_mod_scripting crate" +repository = "https://github.com/makspll/bevy_mod_scripting" +homepage = "https://github.com/makspll/bevy_mod_scripting" +keywords = ["bevy", "gamedev", "scripting", "documentation", "generator"] +categories = ["game-development", "development-tools"] +include = ["readme.md", "/src"] +readme = "readme.md" + +[dependencies] +clap = "4" +mdbook = "0.4" +ladfile = { path = "../../ladfile", version = "0.1.1" } +env_logger = "0.11" +log = "0.4" +serde_json = "1.0" + +[dev-dependencies] +assert_cmd = "2.0" +pretty_assertions = "1.4.1" + +[lints] +workspace = true + +[[bin]] +name = "mdbook-lad-preprocessor" +path = "src/main.rs" diff --git a/crates/lad_backends/mdbook_lad_preprocessor/readme.md b/crates/lad_backends/mdbook_lad_preprocessor/readme.md new file mode 100644 index 0000000000..8bd1d758d2 --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/readme.md @@ -0,0 +1,25 @@ +# LAD Preprocessor for mdbook + +This is a preprocessor for `mdbook` that allows you to include `LAD` files in your markdown files. + +## Usage + +Add the following to your `book.toml`: + +```toml +[preprocessor.lad_preprocessor] +``` + +Then any files with the `.lad.json` extension will be processed by the preprocessor. + +So for example if you have the following structure: + +```markdown +- [Normal file](normal_file.md) +- [LAD file](lad_file.lad.json) +``` + +The `lad_file.lad.json` will be processed by the preprocessor, and appropriate nested markdown will be generated from there on out using the `LAD file` chapter as the parent page. + +If the file is not found + diff --git a/crates/lad_backends/mdbook_lad_preprocessor/src/lib.rs b/crates/lad_backends/mdbook_lad_preprocessor/src/lib.rs new file mode 100644 index 0000000000..6b0eb39c5d --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/src/lib.rs @@ -0,0 +1,77 @@ +//! The library crate for the mdbook LAD preprocessor. +#![allow(missing_docs)] + +use mdbook::{errors::Error, preprocess::Preprocessor}; +mod markdown; +mod sections; + +const LAD_EXTENSION: &str = "lad.json"; + +pub struct LADPreprocessor; + +impl Preprocessor for LADPreprocessor { + fn name(&self) -> &str { + "lad-preprocessor" + } + + fn run( + &self, + _ctx: &mdbook::preprocess::PreprocessorContext, + mut book: mdbook::book::Book, + ) -> mdbook::errors::Result { + let mut errors = Vec::default(); + + book.for_each_mut(|item| { + if let mdbook::BookItem::Chapter(chapter) = item { + let is_lad_chapter = chapter + .source_path + .as_ref() + .and_then(|a| a.file_name()) + .is_some_and(|a| a.to_string_lossy().ends_with(LAD_EXTENSION)); + + if !is_lad_chapter { + log::debug!("Skipping non-LAD chapter: {:?}", chapter.source_path); + return; + } + + let chapter_title = chapter.name.clone(); + + let lad = match ladfile::parse_lad_file(&chapter.content) { + Ok(lad) => lad, + Err(e) => { + log::debug!("Failed to parse LAD file: {:?}", e); + errors.push(Error::new(e).context("Failed to parse LAD file")); + return; + } + }; + + log::debug!("Parsed LAD file: {:?}", lad); + + let sections = sections::lad_file_to_sections(&lad, Some(chapter_title)); + + let new_chapter = sections::section_to_chapter( + sections, + Some(chapter), + chapter.parent_names.clone(), + chapter.number.clone(), + None, + None, + ); + + log::debug!("New chapter: {:?}", new_chapter); + + *chapter = new_chapter; + } + }); + + if !errors.is_empty() { + // return on first error + for error in errors { + log::error!("{}", error); + Err(error)?; + } + } + + Ok(book) + } +} diff --git a/crates/lad_backends/mdbook_lad_preprocessor/src/main.rs b/crates/lad_backends/mdbook_lad_preprocessor/src/main.rs new file mode 100644 index 0000000000..335c6517c9 --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/src/main.rs @@ -0,0 +1,65 @@ +#![allow(missing_docs)] +use clap::{Arg, Command}; +use env_logger::Builder; +use log::LevelFilter; +use mdbook::preprocess::{CmdPreprocessor, Preprocessor}; +use mdbook_lad_preprocessor::LADPreprocessor; +use std::{env, fs::File, io, process::exit}; + +// use mdbook_lad_preprocessor::LADPreprocessor; + +fn init_logger() { + let mut builder = Builder::new(); + + if let Ok(var) = env::var("RUST_LOG") { + builder.parse_filters(&var); + } else { + builder.filter(None, LevelFilter::Info); + } + + // target lad.log file in current directory + // print pwd + if let Ok(file) = File::create("./lad.log") { + let target = Box::new(file); + builder.target(env_logger::Target::Pipe(target)); + } + + builder.init(); + + log::debug!("Debug logging enabled"); +} + +pub fn make_app() -> Command { + Command::new("nop-preprocessor") + .about("A mdbook preprocessor which does precisely nothing") + .subcommand( + Command::new("supports") + .arg(Arg::new("renderer").required(true)) + .about("Check whether a renderer is supported by this preprocessor"), + ) +} + +fn main() -> Result<(), Box> { + init_logger(); + let matches = make_app().get_matches(); + if let Some(sub_args) = matches.subcommand_matches("supports") { + let renderer = match sub_args.get_one::("renderer") { + Some(r) => r, + None => { + log::error!("No renderer specified"); + exit(1) + } + }; + + if LADPreprocessor.supports_renderer(renderer) { + exit(0) + } else { + exit(1) + } + } else { + let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?; + let processed_book = LADPreprocessor.run(&ctx, book)?; + serde_json::to_writer(io::stdout(), &processed_book)?; + exit(0) + } +} diff --git a/crates/lad_backends/mdbook_lad_preprocessor/src/markdown.rs b/crates/lad_backends/mdbook_lad_preprocessor/src/markdown.rs new file mode 100644 index 0000000000..03d5d811d8 --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/src/markdown.rs @@ -0,0 +1,411 @@ +/// Escapes Markdown reserved characters in the given text. +fn escape_markdown(text: &str) -> String { + // Characters that should be escaped in markdown + let escape_chars = r"\`*_{}[]()#+-.!"; + let mut escaped = String::with_capacity(text.len()); + for c in text.chars() { + if escape_chars.contains(c) { + escaped.push('\\'); + } + escaped.push(c); + } + escaped +} + +/// Trait for converting elements into markdown strings. +pub trait IntoMarkdown { + fn to_markdown(&self, builder: &mut MarkdownBuilder); +} + +/// Comprehensive enum representing various Markdown constructs. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum Markdown { + Heading { + level: u8, + text: String, + }, + Paragraph(String), + InlineCode(String), + CodeBlock { + language: Option, + code: String, + }, + List { + ordered: bool, + items: Vec, + }, + Quote(String), + Image { + alt: String, + src: String, + }, + Link { + text: String, + url: String, + anchor: bool, + }, + HorizontalRule, + Table { + headers: Vec, + rows: Vec>, + }, +} + +impl IntoMarkdown for Markdown { + fn to_markdown(&self, builder: &mut MarkdownBuilder) { + match self { + Markdown::Heading { level, text } => { + // Clamp the header level to Markdown's 1-6. + let clamped_level = level.clamp(&1, &6); + let hashes = "#".repeat(*clamped_level as usize); + // Escape the text for Markdown + builder.append(&format!("{} {}", hashes, escape_markdown(text))); + } + Markdown::Paragraph(text) => { + builder.append(&escape_markdown(text)); + } + Markdown::CodeBlock { language, code } => { + // Do not escape code blocks + let lang = language.as_deref().unwrap_or(""); + builder.append(&format!("```{}\n{}\n```", lang, code)); + } + Markdown::InlineCode(code) => { + // Do not escape inline code + builder.append(&format!("`{}`", code)); + } + Markdown::List { ordered, items } => { + let list_output = items + .iter() + .enumerate() + .map(|(i, item)| { + let escaped_item = escape_markdown(item); + if *ordered { + format!("{}. {}", i + 1, escaped_item) + } else { + format!("- {}", escaped_item) + } + }) + .collect::>() + .join("\n"); + builder.append(&list_output); + } + Markdown::Quote(text) => { + let quote_output = text + .lines() + .map(|line| format!("> {}", escape_markdown(line))) + .collect::>() + .join("\n"); + builder.append("e_output); + } + Markdown::Image { alt, src } => { + // Escape alt text while leaving src untouched. + builder.append(&format!("![{}]({})", escape_markdown(alt), src)); + } + Markdown::Link { text, url, anchor } => { + // anchors must be lowercase, only contain letters or dashes + let url = if *anchor { + // prefix with # + format!( + "#{}", + url.to_lowercase() + .replace(" ", "-") + .replace(|c: char| !c.is_alphabetic(), "") + ) + } else { + url.clone() + }; + // Escape link text while leaving url untouched. + builder.append(&format!("[{}]({})", escape_markdown(text), url)); + } + Markdown::HorizontalRule => { + builder.append("---"); + } + Markdown::Table { headers, rows } => { + // Generate a Markdown table: + // Header row: + let header_line = format!("| {} |", headers.join(" | ")); + // Separator row: + let separator_line = format!( + "|{}|", + headers + .iter() + .map(|_| " --- ") + .collect::>() + .join("|") + ); + // Rows: + let rows_lines = rows + .iter() + .map(|row| format!("| {} |", row.join(" | "))) + .collect::>() + .join("\n"); + builder.append(&format!( + "{}\n{}\n{}", + header_line, separator_line, rows_lines + )); + } + } + } +} + +/// Builder pattern for generating comprehensive Markdown documentation. +/// Now also doubles as the accumulator for the generated markdown. +pub struct MarkdownBuilder { + elements: Vec, + output: String, + inline: bool, +} + +#[allow(dead_code)] +impl MarkdownBuilder { + /// Creates a new MarkdownBuilder. + pub fn new() -> Self { + MarkdownBuilder { + elements: Vec::new(), + output: String::new(), + inline: false, + } + } + + pub fn inline(&mut self) -> &mut Self { + self.inline = true; + self + } + + /// Adds an in-place slot for more complex markdown generation while preserving the builder flow. + pub fn complex(&mut self, f: impl FnOnce(&mut MarkdownBuilder)) -> &mut Self { + f(self); + self + } + + /// Appends raw text to the output without processing it. + pub fn append(&mut self, text: &str) { + self.output.push_str(text); + } + + /// Adds a heading element (Levels from 1-6). + pub fn heading(&mut self, level: u8, text: impl Into) -> &mut Self { + self.elements.push(Markdown::Heading { + level: level.min(6), + text: text.into(), + }); + self + } + + /// Adds a paragraph element. + pub fn paragraph(&mut self, text: impl Into) -> &mut Self { + self.elements.push(Markdown::Paragraph(text.into())); + self + } + + /// Adds a code block element. + pub fn codeblock( + &mut self, + language: Option>, + code: impl Into, + ) -> &mut Self { + self.elements.push(Markdown::CodeBlock { + language: language.map(|l| l.into()), + code: code.into(), + }); + self + } + + /// Adds an inline code element. + pub fn inline_code(&mut self, code: impl Into) -> &mut Self { + self.elements.push(Markdown::InlineCode(code.into())); + self + } + + /// Adds a list element. + pub fn list(&mut self, ordered: bool, items: Vec>) -> &mut Self { + let converted_items: Vec = items.into_iter().map(|s| s.into()).collect(); + self.elements.push(Markdown::List { + ordered, + items: converted_items, + }); + self + } + + /// Adds a quote element. + pub fn quote(&mut self, text: impl Into) -> &mut Self { + self.elements.push(Markdown::Quote(text.into())); + self + } + + /// Adds an image element. + pub fn image(&mut self, alt: impl Into, src: impl Into) -> &mut Self { + self.elements.push(Markdown::Image { + alt: alt.into(), + src: src.into(), + }); + self + } + + /// Adds a link element. + pub fn link(&mut self, text: impl Into, url: impl Into) -> &mut Self { + self.elements.push(Markdown::Link { + text: text.into(), + url: url.into(), + anchor: false, + }); + self + } + + pub fn section_link(&mut self, text: impl Into, url: impl Into) -> &mut Self { + self.elements.push(Markdown::Link { + text: text.into(), + url: url.into(), + anchor: true, + }); + self + } + + /// Adds a horizontal rule element. + pub fn horizontal_rule(&mut self) -> &mut Self { + self.elements.push(Markdown::HorizontalRule); + self + } + + /// Adds a table element via a mini builder. + pub fn table(&mut self, f: impl FnOnce(&mut TableBuilder)) -> &mut Self { + let mut builder = TableBuilder::new(); + f(&mut builder); + self.elements.push(builder.build()); + self + } + + /// Builds the markdown document as a single String by delegating the conversion + /// of each element to its `into_markdown` implementation. + pub fn build(&mut self) -> String { + let len = self.elements.len(); + for (i, element) in self.elements.clone().into_iter().enumerate() { + element.to_markdown(self); + if i < len - 1 { + if self.inline { + self.append(" "); + } else { + self.append("\n\n"); + } + } + } + self.output.clone() + } +} + +/// Mini builder for constructing Markdown tables. +pub struct TableBuilder { + headers: Vec, + rows: Vec>, +} + +impl TableBuilder { + /// Creates a new TableBuilder. + pub fn new() -> Self { + TableBuilder { + headers: vec![], + rows: vec![], + } + } + + /// Sets the headers for the table. + pub fn headers(&mut self, headers: Vec>) -> &mut Self { + self.headers = headers.into_iter().map(|h| h.into()).collect(); + self + } + + /// Adds a row to the table. + pub fn row(&mut self, row: Vec>) -> &mut Self { + let converted: Vec = row.into_iter().map(|cell| cell.into()).collect(); + self.rows.push(converted); + self + } + + /// Finalizes and builds the table as a Markdown variant. + pub fn build(self) -> Markdown { + Markdown::Table { + headers: self.headers, + rows: self.rows, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_markdown_builder() { + let mut builder = MarkdownBuilder::new(); + let markdown = builder + .heading(1, "Documentation Title *with special chars*") + .paragraph("This is the introduction with some _underscores_ and `backticks`.") + .codeblock(Some("rust"), "fn main() { println!(\"Hello, world!\"); }") + .list( + false, + vec![ + "First bullet with #hash", + "Second bullet with [brackets]", + "Third bullet with (parentheses)", + ], + ) + .quote("This is a quote!\nIt spans multiple lines.") + .image( + "Rust Logo", + "https://www.rust-lang.org/logos/rust-logo-512x512.png", + ) + .link("Rust Homepage", "https://www.rust-lang.org") + .horizontal_rule() + .table(|table| { + table + .headers(vec!["Header 1", "Header 2"]) + .row(vec!["Row 1 Col 1", "Row 1 Col 2"]) + .row(vec!["Row 2 Col 1", "Row 2 Col 2"]); + }) + .build(); + let expected = r#" + # Documentation Title \*with special chars\* + + This is the introduction with some \_underscores\_ and \`backticks\`\. + + ```rust + fn main() { println!("Hello, world!"); } + ``` + + - First bullet with \#hash + - Second bullet with \[brackets\] + - Third bullet with \(parentheses\) + + > This is a quote\! + > It spans multiple lines\. + + ![Rust Logo](https://www.rust-lang.org/logos/rust-logo-512x512.png) + + [Rust Homepage](https://www.rust-lang.org) + + --- + + | Header 1 | Header 2 | + | --- | --- | + | Row 1 Col 1 | Row 1 Col 2 | + | Row 2 Col 1 | Row 2 Col 2 | + "#; + + let trimmed_indentation_expected = expected + .lines() + .map(|line| line.trim()) + .collect::>() + .join("\n"); + let trimmed_indentation_expected = trimmed_indentation_expected.trim(); + + let trimmed_indentation_markdown = markdown + .lines() + .map(|line| line.trim()) + .collect::>() + .join("\n"); + let trimmed_indentation_markdown = trimmed_indentation_markdown.trim(); + + pretty_assertions::assert_eq!(trimmed_indentation_expected, trimmed_indentation_markdown); + } +} diff --git a/crates/lad_backends/mdbook_lad_preprocessor/src/sections.rs b/crates/lad_backends/mdbook_lad_preprocessor/src/sections.rs new file mode 100644 index 0000000000..e3719680b9 --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/src/sections.rs @@ -0,0 +1,348 @@ +use std::{borrow::Cow, path::PathBuf}; + +use ladfile::{LadFunction, LadInstance, LadType, LadTypeLayout}; +use mdbook::book::{Chapter, SectionNumber}; + +use crate::markdown::{IntoMarkdown, MarkdownBuilder}; + +pub(crate) fn section_to_chapter( + section: SectionAndChildren, + original_chapter: Option<&Chapter>, + parent_names: Vec, + number: Option, + root_path: Option, + root_source_path: Option, +) -> Chapter { + let mut parent_builder = MarkdownBuilder::new(); + section.section.to_markdown(&mut parent_builder); + + let new_path = root_path + .unwrap_or_default() + .join(section.section.file_name()); + + let new_source_path = root_source_path + .unwrap_or_default() + .join(section.section.file_name()); + + let current_number = number.clone().unwrap_or_default(); + + let children_chapters = section + .children + .into_iter() + .enumerate() + .map(|(index, child)| { + let mut new_number = current_number.clone(); + new_number.push(index as u32); + section_to_chapter( + child, + None, + vec![section.section.title()], + Some(new_number), + Some(new_path.clone()), + Some(new_source_path.clone()), + ) + }) + .map(mdbook::BookItem::Chapter) + .collect(); + + if let Some(original) = original_chapter { + // override content only + Chapter { + content: parent_builder.build(), + sub_items: children_chapters, + ..original.clone() + } + } else { + Chapter { + name: section.section.title(), + content: parent_builder.build(), + number, + sub_items: children_chapters, + path: Some(new_path), + source_path: Some(new_source_path), + parent_names, + } + } +} + +pub(crate) fn lad_file_to_sections( + ladfile: &ladfile::LadFile, + title: Option, +) -> SectionAndChildren<'_> { + let summary = Section::Summary { ladfile, title }; + + let children = ladfile + .types + .iter() + .map(|(_, lad_type)| Section::TypeDetail { lad_type, ladfile }) + .map(|section| SectionAndChildren { + section, + children: Vec::new(), + }) + .collect(); + + SectionAndChildren { + section: summary, + children, + } +} +pub(crate) struct SectionAndChildren<'a> { + section: Section<'a>, + children: Vec>, +} + +/// Sections which convert to single markdown files +pub(crate) enum Section<'a> { + Summary { + ladfile: &'a ladfile::LadFile, + title: Option, + }, + TypeDetail { + lad_type: &'a LadType, + ladfile: &'a ladfile::LadFile, + }, +} + +impl Section<'_> { + pub(crate) fn title(&self) -> String { + match self { + Section::Summary { title, .. } => { + title.as_deref().unwrap_or("Bindings Summary").to_owned() + } + Section::TypeDetail { + lad_type: type_id, .. + } => type_id.identifier.clone(), + } + } + + pub(crate) fn file_name(&self) -> String { + self.title().to_lowercase().replace(" ", "_") + } + + pub(crate) fn section_items(&self) -> Vec { + match self { + Section::Summary { ladfile, .. } => { + let types = ladfile.types.values().collect::>(); + let instances = ladfile.globals.iter().collect::>(); + vec![ + SectionItem::InstancesSummary { instances }, + SectionItem::TypesSummary { types }, + ] + } + Section::TypeDetail { + lad_type: type_id, + ladfile, + } => { + let functions = type_id + .associated_functions + .iter() + .filter_map(|i| ladfile.functions.get(i)) + .collect::>(); + + vec![ + SectionItem::Layout { + layout: &type_id.layout, + }, + SectionItem::Description { lad_type: type_id }, + SectionItem::FunctionsSummary { functions }, + ] + } + } + } +} + +impl IntoMarkdown for Section<'_> { + fn to_markdown(&self, builder: &mut MarkdownBuilder) { + builder.heading(1, self.title()); + + for item in self.section_items() { + item.to_markdown(builder); + } + } +} + +/// Items which combine markdown elements to build a section +pub enum SectionItem<'a> { + Layout { + layout: &'a LadTypeLayout, + }, + Description { + lad_type: &'a LadType, + }, + FunctionsSummary { + functions: Vec<&'a LadFunction>, + }, + TypesSummary { + types: Vec<&'a LadType>, + }, + InstancesSummary { + instances: Vec<(&'a Cow<'static, str>, &'a LadInstance)>, + }, +} + +impl IntoMarkdown for SectionItem<'_> { + fn to_markdown(&self, builder: &mut MarkdownBuilder) { + match self { + SectionItem::Layout { layout } => { + // process the variants here + let opaque = layout.for_each_variant( + |v, i| match v { + ladfile::LadVariant::TupleStruct { name, fields } => { + builder.heading(2, format!("{}", i)).complex(|builder| { + builder.paragraph(format!("Tuple struct: {}", name)).list( + true, + fields + .iter() + .enumerate() + .map(|(i, f)| format!("{i}: {}", f.type_)) + .collect(), + ); + }); + } + ladfile::LadVariant::Struct { name, fields } => { + builder.heading(2, format!("{}", i)).complex(|builder| { + builder.paragraph(format!("Struct: {}", name)).list( + false, + fields + .iter() + .map(|f| format!("{}: {}", f.name, f.type_)) + .collect(), + ); + }); + } + ladfile::LadVariant::Unit { name } => { + builder.heading(2, format!("{}", i)).complex(|builder| { + builder.paragraph(format!("Unit: {}", name)); + }); + } + }, + "Opaque Type. 🔒", + ); + + if let Some(opaque) = opaque { + builder.paragraph(opaque); + } + } + SectionItem::Description { + lad_type: description, + } => { + builder.heading(2, "Description").quote( + description + .documentation + .as_deref() + .unwrap_or("None available. 🚧"), + ); + } + SectionItem::FunctionsSummary { functions } => { + builder.heading(2, "Functions"); + + // make a table of functions as a quick reference, make them link to function details sub-sections + builder.table(|builder| { + builder.headers(vec!["Function", "Summary"]); + for function in functions.iter() { + let mut first_col = function.identifier.to_string(); + first_col.push('('); + for (idx, arg) in function.arguments.iter().enumerate() { + first_col.push_str( + &arg.name + .as_ref() + .cloned() + .unwrap_or_else(|| Cow::Owned(format!("arg{}", idx))), + ); + if idx != function.arguments.len() - 1 { + first_col.push_str(", "); + } + } + first_col.push(')'); + + // first line with content from documentation trimmed to 100 chars + let second_col = function + .documentation + .as_deref() + .map(|doc| { + let doc = doc.trim(); + if doc.len() > 100 { + format!("{}...", &doc[..100]) + } else { + doc.to_owned() + } + }) + .unwrap_or_else(|| "No documentation available. 🚧".to_owned()); + + let mut body_markdown = MarkdownBuilder::new(); + + body_markdown.inline().inline_code(first_col); + + let mut second_col_markdown = MarkdownBuilder::new(); + + second_col_markdown + .inline() + .link(second_col, function.identifier.to_string()); + + builder.row(vec![body_markdown.build(), second_col_markdown.build()]); + } + }); + } + SectionItem::TypesSummary { types } => { + builder.heading(2, "Types"); + + // make a table of types as a quick reference, make them link to type details sub-sections + builder.table(|builder| { + builder.headers(vec!["Type", "Summary"]); + for type_ in types.iter() { + let first_col = type_.identifier.to_string(); + + // first line with content from documentation trimmed to 100 chars + let second_col = type_ + .documentation + .as_deref() + .map(|doc| { + let doc = doc.trim(); + if doc.len() > 100 { + format!("{}...", &doc[..100]) + } else { + doc.to_owned() + } + }) + .unwrap_or_else(|| "No documentation available. 🚧".to_owned()); + + let mut body_markdown = MarkdownBuilder::new(); + + body_markdown.inline().inline_code(first_col); + + let mut second_col_markdown = MarkdownBuilder::new(); + + second_col_markdown + .inline() + .link(second_col, type_.identifier.to_string()); + + builder.row(vec![body_markdown.build(), second_col_markdown.build()]); + } + }); + } + SectionItem::InstancesSummary { instances } => { + builder.heading(2, "Globals"); + + // make a table of instances as a quick reference, make them link to instance details sub-sections + builder.table(|builder| { + builder.headers(vec!["Instance", "Type"]); + for (key, instance) in instances.iter() { + let first_col = key.to_string(); + + let mut body_markdown = MarkdownBuilder::new(); + + body_markdown.inline().inline_code(first_col); + + let mut second_col_markdown = MarkdownBuilder::new(); + + second_col_markdown + .inline() + .link(instance.type_id.to_string(), instance.type_id.to_string()); + + builder.row(vec![body_markdown.build(), second_col_markdown.build()]); + } + }); + } + } + } +} diff --git a/crates/lad_backends/mdbook_lad_preprocessor/tests/book_integration_tests.rs b/crates/lad_backends/mdbook_lad_preprocessor/tests/book_integration_tests.rs new file mode 100644 index 0000000000..8c5e206a53 --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/tests/book_integration_tests.rs @@ -0,0 +1,60 @@ +#![allow(missing_docs, clippy::expect_used, clippy::unwrap_used)] + +use std::path::PathBuf; + +use assert_cmd::Command; +fn add_executable_dir_to_path() { + let command_path = Command::cargo_bin("mdbook-lad-preprocessor") + .expect("failed to find mdbook-lad-preprocessor binary"); + let command_path = command_path.get_program(); + let command_path = PathBuf::from(command_path); + let dir = command_path + .parent() + .expect("failed to get parent directory"); + let mut paths = std::env::split_paths(&std::env::var("PATH").expect("failed to get PATH")) + .collect::>(); + paths.push(dir.to_owned()); + std::env::set_var( + "PATH", + std::env::join_paths(paths).expect("failed to join paths"), + ); +} + +// use cargo manifest dir +fn get_books_dir() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let manifest_dir = std::path::PathBuf::from(manifest_dir); + manifest_dir.join("tests").join("books") +} + +fn copy_ladfile_to_book_dir(book_dir: &std::path::Path, ladfile: &str) { + let ladfile_path = get_books_dir().join(ladfile); + let book_ladfile_path = book_dir.join("src").join("test.lad.json"); + println!( + "Copying LAD file from {:?} to {:?}", + ladfile_path, book_ladfile_path + ); + std::fs::copy(ladfile_path, book_ladfile_path).expect("failed to copy LAD file"); +} + +#[test] +fn test_on_example_ladfile() { + // invoke mdbook build + // assert that the output contains the expected content + + add_executable_dir_to_path(); + + let books_dir = get_books_dir(); + let book = "example_ladfile"; + + let ladfile_path = "../../../../ladfile/test_assets/test.lad.json"; + + copy_ladfile_to_book_dir(&books_dir.join(book), ladfile_path); + + Command::new("mdbook") + .env("RUST_LOG", "debug") + .current_dir(books_dir.join(book)) + .arg("build") + .assert() + .success(); +} diff --git a/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/.gitignore b/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/.gitignore new file mode 100644 index 0000000000..f393d3795e --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/.gitignore @@ -0,0 +1,2 @@ +book +test.lad.json \ No newline at end of file diff --git a/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/book.toml b/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/book.toml new file mode 100644 index 0000000000..2abfacddbc --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/book.toml @@ -0,0 +1,10 @@ +[book] +authors = ["Maksymilian Mozolewski"] +language = "en" +multilingual = false +src = "src" +title = "Bevy Scripting" +description = "Documentation for the Bevy Scripting library" + + +[preprocessor.lad-preprocessor] diff --git a/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/src/SUMMARY.md b/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/src/SUMMARY.md new file mode 100644 index 0000000000..cb66e2c741 --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/src/SUMMARY.md @@ -0,0 +1,8 @@ + +- [Some markdown](some_markdown.md) + +- [LAD](test.lad.json) + +- [Some more markdown](some_markdown.md) + + - [Nested Lad](test.lad.json) \ No newline at end of file diff --git a/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/src/some_markdown.md b/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/src/some_markdown.md new file mode 100644 index 0000000000..44b705ed0e --- /dev/null +++ b/crates/lad_backends/mdbook_lad_preprocessor/tests/books/example_ladfile/src/some_markdown.md @@ -0,0 +1,3 @@ +# Blah + +blah \ No newline at end of file diff --git a/crates/ladfile/src/lib.rs b/crates/ladfile/src/lib.rs index 103b7ebf21..2fe1fe8a46 100644 --- a/crates/ladfile/src/lib.rs +++ b/crates/ladfile/src/lib.rs @@ -1,7 +1,10 @@ //! Parsing definitions for the LAD (Language Agnostic Decleration) file format. use bevy_mod_scripting_core::{ - bindings::{function::script_function::FunctionCallContext, ReflectReference}, + bindings::{ + function::{namespace::Namespace, script_function::FunctionCallContext}, + ReflectReference, + }, docgen::{ info::FunctionInfo, typed_through::{ThroughTypeInfo, TypedWrapperKind, UntypedWrapperKind}, @@ -36,6 +39,10 @@ pub struct LadFile { /// A mapping from type ids to primitive types pub primitives: IndexMap, + + /// A description of the LAD file and its contents in markdown + #[serde(skip_serializing_if = "Option::is_none", default)] + pub description: Option, } impl LadFile { @@ -47,6 +54,7 @@ impl LadFile { types: IndexMap::new(), functions: IndexMap::new(), primitives: IndexMap::new(), + description: None, } } } @@ -77,6 +85,12 @@ pub struct LadInstance { /// Only unique within the LAD file. pub struct LadFunctionId(String); +impl std::fmt::Display for LadFunctionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + impl LadFunctionId { /// Create a new LAD function id with a string. pub fn new_string_id(function_id: String) -> Self { @@ -87,6 +101,8 @@ impl LadFunctionId { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] /// A function definition used in a LAD file. pub struct LadFunction { + /// The namespace of the function. + pub namespace: LadFunctionNamespace, /// The identifier or name of the function. pub identifier: Cow<'static, str>, /// The argument information for the function. @@ -99,6 +115,16 @@ pub struct LadFunction { pub documentation: Option>, } +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +/// A function namespace used in a LAD file. +pub enum LadFunctionNamespace { + /// A function in a type's namespace + Type(LadTypeId), + /// A global function + Global, +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] /// An argument definition used in a LAD file. pub struct LadArgument { @@ -265,6 +291,12 @@ impl LadBMSPrimitiveKind { /// It *might* be unique across LAD files, but this is not guaranteed and depends on the type itself. pub struct LadTypeId(Cow<'static, str>); +impl std::fmt::Display for LadTypeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + impl LadTypeId { /// Create a new LAD type id with a specific index. pub fn new_string_id(type_id: Cow<'static, str>) -> Self { @@ -317,6 +349,32 @@ pub enum LadTypeLayout { Enum(Vec), } +impl LadTypeLayout { + /// Traverses the layout in a depth-first manner and calls the provided function on each variant in order of appearance. + /// Calls the function with the variant and its index in the layout starting from 0. + /// + /// If the layout is opaque, Some with the provided default is returned + pub fn for_each_variant( + &self, + mut f: F, + default: D, + ) -> Option { + match self { + LadTypeLayout::Opaque => Some(default), + LadTypeLayout::MonoVariant(variant) => { + f(variant, 0); + None + } + LadTypeLayout::Enum(variants) => { + for (i, variant) in variants.iter().enumerate() { + f(variant, i); + } + None + } + } + } +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(tag = "kind")] /// A variant definition used in a LAD file. @@ -355,16 +413,19 @@ pub enum LadVariant { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] /// A field definition used in a LAD file. pub struct LadField { + /// The type of the field. #[serde(rename = "type")] - type_: LadTypeId, + pub type_: LadTypeId, } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] /// A named field definition used in a LAD file. pub struct LadNamedField { - name: String, + /// The name of the field. + pub name: String, #[serde(rename = "type")] - type_: LadTypeId, + /// The type of the field. + pub type_: LadTypeId, } /// A generic type definition used in a LAD file. @@ -532,11 +593,23 @@ impl<'t> LadFileBuilder<'t> { .collect(), return_type: self.lad_id_from_type_id(function_info.return_info.type_id), documentation: function_info.docs, + namespace: match function_info.namespace { + Namespace::Global => LadFunctionNamespace::Global, + Namespace::OnType(type_id) => { + LadFunctionNamespace::Type(self.lad_id_from_type_id(type_id)) + } + }, }; self.file.functions.insert(function_id, lad_function); self } + /// Set the markdown description of the LAD file. + pub fn set_description(&mut self, description: impl Into) -> &mut Self { + self.file.description = Some(description.into()); + self + } + /// Build the finalized and optimized LAD file. pub fn build(&mut self) -> LadFile { let mut file = std::mem::replace(&mut self.file, LadFile::new()); @@ -546,6 +619,18 @@ impl<'t> LadFileBuilder<'t> { file.primitives.sort_keys(); } + // associate functions on type namespaces with their types + for (function_id, function) in file.functions.iter() { + match &function.namespace { + LadFunctionNamespace::Type(type_id) => { + if let Some(t) = file.types.get_mut(type_id) { + t.associated_functions.push(function_id.clone()); + } + } + LadFunctionNamespace::Global => {} + } + } + file } @@ -832,6 +917,7 @@ mod test { .with_arg_names(&["arg1"]); let mut lad_file = LadFileBuilder::new(&type_registry) + .set_description("## Hello gentlemen\n I am markdown file.\n - hello\n - world") .set_sorted(true) .add_function_info(function_info) .add_function_info(global_function_info) diff --git a/crates/ladfile/test_assets/test.lad.json b/crates/ladfile/test_assets/test.lad.json index feee166ac8..d1e62337a7 100644 --- a/crates/ladfile/test_assets/test.lad.json +++ b/crates/ladfile/test_assets/test.lad.json @@ -55,6 +55,9 @@ } ], "documentation": " I am a struct", + "associated_functions": [ + "ladfile::test::StructType::hello_world" + ], "layout": { "kind": "Struct", "name": "StructType", @@ -101,6 +104,7 @@ }, "functions": { "::hello_world": { + "namespace": null, "identifier": "hello_world", "arguments": [ { @@ -113,6 +117,7 @@ "return_type": "usize" }, "ladfile::test::StructType::hello_world": { + "namespace": "ladfile::test::StructType", "identifier": "hello_world", "arguments": [ { @@ -245,5 +250,6 @@ "kind": "usize", "documentation": "An unsigned pointer-sized integer" } - } + }, + "description": "## Hello gentlemen\n I am markdown file.\n - hello\n - world" } \ No newline at end of file