mod page;
mod reference;
mod render;
mod schema;
use page::{AUTOGEN_MARKER, IndexEntry, render_index, render_page};
use reference::AssetDoc;
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
const PAGES_DIR: &str = "docs/assets";
fn pages(docs: &[AssetDoc]) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for d in docs {
out.insert(
format!("{}.md", d.type_name),
render_page(&d.type_name, &d.full_doc),
);
}
let index = |reference_types: bool| -> Vec<IndexEntry> {
docs.iter()
.filter(|d| d.is_reference_type == reference_types)
.map(|d| IndexEntry {
name: d.type_name.clone(),
summary: d.summary.clone(),
})
.collect()
};
out.insert(
"index.md".to_string(),
render_index(&index(false), &index(true)),
);
out
}
pub fn docs(root: Option<&str>) -> io::Result<()> {
let engine_root = PathBuf::from(root.unwrap_or("."));
let pages = pages(&reference::build(&engine_root)?);
let dir = engine_root.join(PAGES_DIR);
let (written, removed) = write_pages(&dir, &pages)?;
println!(
"{} asset pages in {} ({written} written, {removed} removed)",
pages.len(),
dir.display()
);
Ok(())
}
fn write_pages(dir: &Path, pages: &BTreeMap<String, String>) -> io::Result<(usize, usize)> {
fs::create_dir_all(dir)?;
let mut written = 0usize;
for (file, content) in pages {
let path = dir.join(file);
if fs::read_to_string(&path).ok().as_deref() == Some(content.as_str()) {
continue;
}
fs::write(&path, content)?;
written += 1;
}
Ok((written, remove_stale_pages(dir, pages)?))
}
fn remove_stale_pages(dir: &Path, keep: &BTreeMap<String, String>) -> io::Result<usize> {
let mut removed = 0;
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if keep.contains_key(name) {
continue;
}
if fs::read_to_string(&path).is_ok_and(|s| s.starts_with(AUTOGEN_MARKER)) {
fs::remove_file(&path)?;
removed += 1;
}
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCES: &str = r#"
/// A widget in the world.
///
/// The shape it embeds is a [collider](#widgetcollider).
pub struct Widget {
/// The mesh to draw.
pub mesh: String,
/// The shape it collides with.
pub collider: Option<WidgetCollider>,
}
impl Default for Widget {
fn default() -> Self {
Self { mesh: "cube".to_string(), collider: None }
}
}
/// A collider shape a widget embeds.
pub struct WidgetCollider {
/// Half the box's size on each axis.
pub half_extents: [f32; 3],
}
impl Default for WidgetCollider {
fn default() -> Self {
Self { half_extents: [0.5, 0.5, 0.5] }
}
}
/// A gadget that makes noise.
pub struct Gadget {
/// How loud, from silent to full.
pub volume: f32,
}
impl Default for Gadget {
fn default() -> Self {
Self { volume: 1.0 }
}
}
/// Engine bookkeeping no world declares.
pub struct Internal {
/// A counter.
pub ticks: u32,
}
impl Default for Internal {
fn default() -> Self {
Self { ticks: 0 }
}
}
"#;
fn synthetic_reference() -> (concinnity_testing::TempTree, Vec<AssetDoc>) {
let tree = concinnity_testing::TempTree::new();
tree.write("schema/vocabulary.rs", SOURCES);
tree.write("schema/notes.md", "not rust");
let components = [
reference::ComponentMeta::pass_through("Widget", "External"),
reference::ComponentMeta::pass_through("Gadget", "External"),
reference::ComponentMeta::pass_through("Internal", "RuntimeOnly"),
];
let docs = reference::build_from(&[tree.join("schema")], &components)
.expect("the synthetic sources parse");
(tree, docs)
}
#[test]
fn writing_is_complete_and_prunes_only_generated_pages() {
let tree = concinnity_testing::TempTree::new();
let dir = tree.path();
let pages: BTreeMap<String, String> = ["Prop", "Texture"]
.iter()
.map(|n| (format!("{n}.md"), render_page(n, "A body.")))
.collect();
assert_eq!(write_pages(dir, &pages).expect("first run"), (2, 0));
for (file, content) in &pages {
assert_eq!(
&fs::read_to_string(dir.join(file)).expect("written"),
content
);
}
assert_eq!(write_pages(dir, &pages).expect("second run"), (0, 0));
fs::write(dir.join("Gone.md"), format!("{AUTOGEN_MARKER}\n\n# Gone\n")).unwrap();
fs::write(dir.join("notes.md"), "hand written\n").unwrap();
fs::write(dir.join("diagram.png"), format!("{AUTOGEN_MARKER}\n")).unwrap();
assert_eq!(write_pages(dir, &pages).expect("third run"), (0, 1));
assert!(!dir.join("Gone.md").exists(), "stale page should be pruned");
assert!(
dir.join("notes.md").exists(),
"hand-authored page should stay"
);
assert!(dir.join("diagram.png").exists(), "non-page should stay");
}
fn describe<'a>(docs: &'a [AssetDoc], type_name: &str) -> Option<&'a AssetDoc> {
docs.iter()
.find(|d| d.type_name.eq_ignore_ascii_case(type_name))
}
#[test]
fn every_documented_type_is_found_by_name() {
let (_tree, docs) = synthetic_reference();
let d = describe(&docs, "Widget").expect("Widget should be documented");
assert_eq!(d.type_name, "Widget");
assert!(d.full_doc.contains(&d.summary));
assert!(describe(&docs, "widget").is_some());
assert!(describe(&docs, "WIDGET").is_some());
assert!(describe(&docs, "NotARealAsset").is_none());
let embedded = describe(&docs, "WidgetCollider").expect("the embedded type is documented");
assert!(embedded.is_reference_type);
assert!(!d.is_reference_type, "an asset is not a reference type");
assert!(
describe(&docs, "Internal").is_none(),
"a RuntimeOnly component is engine-internal and gets no page"
);
}
#[test]
fn every_type_resolved_documentation() {
let (_tree, docs) = synthetic_reference();
assert_eq!(docs.len(), 3, "two assets and the type they embed");
for d in &docs {
assert!(!d.summary.is_empty(), "{} has no summary", d.type_name);
assert!(
!d.summary.contains('\n'),
"{}'s summary spans multiple lines: {:?}",
d.type_name,
d.summary
);
}
let widget = describe(&docs, "Widget").expect("Widget");
assert_eq!(widget.summary, "A widget in the world.");
assert!(
widget.full_doc.contains("The mesh to draw."),
"{:?}",
widget.full_doc
);
assert!(widget.full_doc.contains("cube"), "{:?}", widget.full_doc);
}
#[test]
fn pages_cover_every_type_plus_the_index() {
let (_tree, docs) = synthetic_reference();
let pages = pages(&docs);
assert_eq!(pages.len(), docs.len() + 1);
assert!(pages.contains_key("index.md"));
for d in &docs {
let page = &pages[&format!("{}.md", d.type_name)];
assert!(page.starts_with(AUTOGEN_MARKER));
assert!(page.contains(&format!("# {}", d.type_name)));
}
}
#[test]
fn no_in_page_anchor_links_remain() {
let (_tree, docs) = synthetic_reference();
let widget = describe(&docs, "Widget").expect("Widget");
assert!(
widget.full_doc.contains("](WidgetCollider.md)"),
"the anchor was rewritten to a relative page link: {:?}",
widget.full_doc
);
for d in &docs {
assert!(
!prose_only(&d.full_doc).contains("](#"),
"{} still has an in-page anchor link outside code: {:?}",
d.type_name,
d.full_doc
);
}
}
fn prose_only(doc: &str) -> String {
let mut out = String::new();
let mut in_fence = false;
for line in doc.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let mut parts = line.split('`');
out.push_str(parts.next().unwrap_or(""));
while let (Some(_code), Some(prose)) = (parts.next(), parts.next()) {
out.push_str(prose);
}
out.push('\n');
}
out
}
}