use super::render::{
EnumValue, FieldEntry, FieldType, render_parameters, render_values, rewrite_doc_links, slug,
};
use super::schema::{self, DocField, DocFieldType, DocShape, DocType, DocValue};
use std::collections::{BTreeSet, HashMap};
use std::io;
use std::path::Path;
pub(super) struct AssetDoc {
pub(super) type_name: String,
pub(super) summary: String,
pub(super) full_doc: String,
pub(super) is_reference_type: bool,
}
const BUILD_ONLY_SCHEMA: &str = "crates/concinnity-cook/src/authoring/schema";
const RUNTIME_SCHEMA: &str = "crates/concinnity-core/src/components";
pub(super) fn build(engine_root: &Path) -> io::Result<Vec<AssetDoc>> {
let authored_src = engine_root.join(BUILD_ONLY_SCHEMA);
let runtime_src = engine_root.join(RUNTIME_SCHEMA);
for dir in [&authored_src, &runtime_src] {
if !dir.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"{} not found: `cn docs` reads the asset prose out of the engine's \
sources, so it runs from a checkout of the engine",
dir.display()
),
));
}
}
build_from(&[authored_src, runtime_src], &collect_registry_components())
}
pub(super) fn build_from(
sources: &[std::path::PathBuf],
components: &[ComponentMeta],
) -> io::Result<Vec<AssetDoc>> {
let mut types = Vec::new();
for dir in sources {
types.extend(schema::extract(std::slice::from_ref(dir), &[])?);
}
let reference = assemble_from(&types, components);
let mut out = Vec::with_capacity(reference.assets.len() + reference.ref_types.len());
for e in reference.assets {
out.push(AssetDoc {
type_name: e.name,
summary: e.summary,
full_doc: e.full_doc,
is_reference_type: false,
});
}
for e in reference.ref_types {
out.push(AssetDoc {
type_name: e.name,
summary: e.summary,
full_doc: e.full_doc,
is_reference_type: true,
});
}
Ok(out)
}
struct Ctx<'a> {
structs: HashMap<&'a str, &'a DocType>,
enums: HashMap<&'a str, &'a DocType>,
comp_by_struct: HashMap<String, String>,
}
impl<'a> Ctx<'a> {
fn new(types: &'a [DocType], comp_by_struct: HashMap<String, String>) -> Self {
let mut structs = HashMap::new();
let mut enums = HashMap::new();
for ty in types {
match ty.shape {
DocShape::Fields(_) => structs.insert(ty.name.as_str(), ty),
DocShape::Values(_) => enums.insert(ty.name.as_str(), ty),
};
}
Ctx {
structs,
enums,
comp_by_struct,
}
}
fn struct_doc(&self, name: &str) -> &'a str {
self.structs.get(name).map_or("", |ty| ty.doc.as_str())
}
fn fields(&self, name: &str) -> &'a [DocField] {
match self.structs.get(name).map(|ty| &ty.shape) {
Some(DocShape::Fields(fields)) => fields,
_ => &[],
}
}
fn enum_values(&self, name: &str) -> Option<(&'a str, &'a [DocValue])> {
match self.enums.get(name).map(|ty| (&ty.doc, &ty.shape)) {
Some((doc, DocShape::Values(values))) => Some((doc.as_str(), values.as_slice())),
_ => None,
}
}
}
fn enum_is_documented(doc: &str, values: &[DocValue]) -> bool {
!doc.trim().is_empty() || values.iter().any(|v| !v.doc.trim().is_empty())
}
pub(super) struct ComponentMeta {
name: String,
struct_ident: String,
args_struct: String,
origin: String,
}
impl ComponentMeta {
#[cfg(test)]
pub(super) fn pass_through(name: &str, origin: &str) -> Self {
Self {
name: name.to_string(),
struct_ident: name.to_string(),
args_struct: name.to_string(),
origin: origin.to_string(),
}
}
}
#[derive(Default)]
struct Refs {
value_types: BTreeSet<String>,
enums: BTreeSet<String>,
}
struct Entry {
name: String,
summary: String,
full_doc: String,
}
struct Reference {
assets: Vec<Entry>,
ref_types: Vec<Entry>,
}
fn assemble_from(types: &[DocType], all_components: &[ComponentMeta]) -> Reference {
let documented: Vec<&ComponentMeta> = all_components
.iter()
.filter(|c| c.origin != "RuntimeOnly")
.collect();
let ctx = Ctx::new(
types,
documented
.iter()
.map(|c| (c.struct_ident.clone(), c.name.clone()))
.collect(),
);
let mut refs = Refs::default();
let mut assets: Vec<Entry> = Vec::new();
for c in &documented {
let (summary, full_doc) =
render_doc_entry(&c.struct_ident, &c.args_struct, &ctx, &mut refs);
assets.push(Entry {
name: c.name.clone(),
summary,
full_doc,
});
}
let mut ref_types: Vec<Entry> = Vec::new();
let mut done_vt: BTreeSet<String> = BTreeSet::new();
loop {
let pending: Vec<String> = refs
.value_types
.iter()
.filter(|n| !done_vt.contains(*n) && ctx.structs.contains_key(n.as_str()))
.cloned()
.collect();
if pending.is_empty() {
break;
}
for name in pending {
done_vt.insert(name.clone());
let (summary, full_doc) = render_doc_entry(&name, &name, &ctx, &mut refs);
let summary = if summary.is_empty() {
"Nested object embedded by other assets.".to_string()
} else {
summary
};
ref_types.push(Entry {
name,
summary,
full_doc,
});
}
}
for name in &refs.enums {
let (summary, full_doc) = render_enum_doc(name, &ctx);
let summary = if summary.is_empty() {
"A set of named string values.".to_string()
} else {
summary
};
ref_types.push(Entry {
name: name.clone(),
summary,
full_doc,
});
}
let mut name_for_slug: HashMap<String, String> = HashMap::new();
for e in assets.iter().chain(ref_types.iter()) {
name_for_slug.insert(slug(&e.name), e.name.clone());
}
for e in assets.iter_mut().chain(ref_types.iter_mut()) {
e.summary = rewrite_doc_links(&e.summary, &name_for_slug);
e.full_doc = rewrite_doc_links(&e.full_doc, &name_for_slug);
}
assets.sort_by(|a, b| a.name.cmp(&b.name));
ref_types.sort_by(|a, b| a.name.cmp(&b.name));
Reference { assets, ref_types }
}
fn collect_registry_components() -> Vec<ComponentMeta> {
use concinnity_cook::authoring::registry::RegisteredType;
RegisteredType::all()
.iter()
.map(|ty| {
let name = ty.as_str().to_string();
ComponentMeta {
name: name.clone(),
struct_ident: name,
args_struct: ty.args_struct_name().to_string(),
origin: format!("{:?}", ty.registration().origin),
}
})
.collect()
}
fn render_doc_entry(
doc_ident: &str,
args_ident: &str,
ctx: &Ctx,
refs: &mut Refs,
) -> (String, String) {
let doc = strip_rust_blocks(ctx.struct_doc(doc_ident));
let cleaned = strip_table_lines(&doc);
let fields = build_fields(args_ident, ctx, refs);
let params = render_parameters(&fields);
let full_doc = combine(&cleaned, ¶ms);
(first_paragraph(&doc), full_doc)
}
fn render_enum_doc(name: &str, ctx: &Ctx) -> (String, String) {
let Some((doc, values)) = ctx.enum_values(name) else {
return (String::new(), String::new());
};
let cleaned = strip_table_lines(&strip_rust_blocks(doc));
let values: Vec<EnumValue> = values
.iter()
.map(|v| EnumValue {
value: v.value.to_string(),
doc: v.doc.to_string(),
})
.collect();
let vals = render_values(&values);
(first_paragraph(doc), combine(&cleaned, &vals))
}
fn combine(description: &str, section: &str) -> String {
match (description.is_empty(), section.is_empty()) {
(_, true) => description.to_string(),
(true, false) => section.to_string(),
(false, false) => format!("{}\n\n{}", description, section.trim_end()),
}
}
fn build_fields(args_ident: &str, ctx: &Ctx, refs: &mut Refs) -> Vec<FieldEntry> {
ctx.fields(args_ident)
.iter()
.map(|f| FieldEntry {
key: f.key.to_string(),
ty: resolve_type(&f.ty, ctx, refs),
optional: f.optional,
default: f.default.clone(),
doc: f.doc.to_string(),
})
.collect()
}
fn resolve_type(ty: &DocFieldType, ctx: &Ctx, refs: &mut Refs) -> FieldType {
match ty {
DocFieldType::Bool => FieldType::Bool,
DocFieldType::Float => FieldType::Float,
DocFieldType::Integer => FieldType::Integer,
DocFieldType::Str => FieldType::Str,
DocFieldType::Object => FieldType::Object,
DocFieldType::Array { elem, len } => FieldType::Array {
elem: Box::new(resolve_type(elem, ctx, refs)),
len: *len,
},
DocFieldType::Name(id) => resolve_name(id, ctx, refs),
}
}
fn resolve_name(id: &str, ctx: &Ctx, refs: &mut Refs) -> FieldType {
if let Some((doc, values)) = ctx.enum_values(id) {
if enum_is_documented(doc, values) {
refs.enums.insert(id.to_string());
FieldType::NamedEnum(id.to_string())
} else {
FieldType::Enum(values.iter().map(|v| v.value.to_string()).collect())
}
} else if let Some(name) = ctx.comp_by_struct.get(id) {
FieldType::Named(name.clone())
} else if ctx.structs.contains_key(id) {
refs.value_types.insert(id.to_string());
FieldType::Named(id.to_string())
} else {
FieldType::Object
}
}
fn first_paragraph(doc: &str) -> String {
let para = doc.split("\n\n").next().unwrap_or("");
para.split('\n')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn strip_rust_blocks(doc: &str) -> String {
let mut out = String::new();
let mut in_rust = false;
for line in doc.lines() {
let trimmed = line.trim();
if in_rust {
if trimmed == "```" {
in_rust = false;
}
continue;
}
if trimmed.starts_with("```rust") || trimmed == "```no_run" || trimmed == "```ignore" {
in_rust = true;
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
fn strip_table_lines(doc: &str) -> String {
let mut out = String::new();
let mut prev_blank = false;
for line in doc.lines() {
let trimmed = line.trim();
if trimmed.starts_with('|') {
continue;
}
let is_blank = trimmed.is_empty();
if is_blank && prev_blank {
continue;
}
out.push_str(line);
out.push('\n');
prev_blank = is_blank;
}
out.trim_end().to_string()
}