use super::attrs::{apply_case, collapse_doc, extract_doc, has_serde_skip, serde_kv};
use super::defaults::{self, UNKNOWN};
use super::model::{DocField, DocFieldType, DocShape, DocType, DocValue};
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
pub(super) fn types(roots: &[PathBuf], exclude: &[PathBuf]) -> io::Result<Vec<DocType>> {
let mut out = Vec::new();
for root in roots {
let mut paths = Vec::new();
collect(root, &mut paths)?;
paths.sort();
for path in paths {
if exclude.iter().any(|e| e == &path) {
continue;
}
file_types(&path, &mut out)?;
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
let entries = std::fs::read_dir(dir)
.map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", dir.display())))?;
for entry in entries {
let path = entry?.path();
if path.is_dir() {
collect(&path, out)?;
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
Ok(())
}
fn file_types(path: &Path, out: &mut Vec<DocType>) -> io::Result<()> {
let src = std::fs::read_to_string(path)
.map_err(|e| io::Error::new(e.kind(), format!("{}: {e}", path.display())))?;
let file = syn::parse_file(&src).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{}: {e}", path.display()),
)
})?;
let defaults = defaults::per_struct(&file);
for item in &file.items {
match item {
syn::Item::Struct(s) => {
let syn::Fields::Named(named) = &s.fields else {
continue;
};
let name = s.ident.to_string();
let fields = struct_fields(named, defaults.get(&name));
out.push(DocType {
doc: extract_doc(&s.attrs),
name,
shape: DocShape::Fields(fields),
});
}
syn::Item::Enum(e) => {
let Some(values) = string_values(e) else {
continue;
};
out.push(DocType {
name: e.ident.to_string(),
doc: extract_doc(&e.attrs),
shape: DocShape::Values(values),
});
}
_ => {}
}
}
Ok(())
}
fn struct_fields(
named: &syn::FieldsNamed,
defaults: Option<&HashMap<String, String>>,
) -> Vec<DocField> {
let mut out = Vec::new();
for field in &named.named {
if has_serde_skip(&field.attrs) {
continue;
}
let Some(ident) = field.ident.as_ref().map(syn::Ident::to_string) else {
continue;
};
let (ty, optional) = match option_inner(&field.ty) {
Some(inner) => (map_type(inner), true),
None => (map_type(&field.ty), false),
};
out.push(DocField {
key: serde_kv(&field.attrs, "rename").unwrap_or_else(|| ident.clone()),
doc: collapse_doc(&extract_doc(&field.attrs)),
ty,
optional,
default: defaults
.and_then(|d| d.get(&ident))
.filter(|d| d.as_str() != UNKNOWN)
.cloned(),
});
}
out
}
fn string_values(e: &syn::ItemEnum) -> Option<Vec<DocValue>> {
if e.variants
.iter()
.any(|v| !matches!(v.fields, syn::Fields::Unit))
{
return None;
}
let rule = serde_kv(&e.attrs, "rename_all");
Some(
e.variants
.iter()
.map(|v| DocValue {
value: serde_kv(&v.attrs, "rename")
.unwrap_or_else(|| apply_case(&v.ident.to_string(), rule.as_deref())),
doc: collapse_doc(&extract_doc(&v.attrs)),
})
.collect(),
)
}
fn map_type(ty: &syn::Type) -> DocFieldType {
match ty {
syn::Type::Path(tp) => {
let Some(seg) = tp.path.segments.last() else {
return DocFieldType::Object;
};
let id = seg.ident.to_string();
match id.as_str() {
"f32" | "f64" => DocFieldType::Float,
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "usize" | "isize" => {
DocFieldType::Integer
}
"bool" => DocFieldType::Bool,
"String" | "AssetId" => DocFieldType::Str,
"TextureHandle"
| "MeshHandle"
| "MaterialHandle"
| "FontHandle"
| "AudioClipHandle"
| "CubemapTextureHandle"
| "EnvironmentMapHandle"
| "ColorLutHandle"
| "SkinnedMeshHandle" => DocFieldType::Str,
"Value" | "HashMap" | "BTreeMap" => DocFieldType::Object,
"Option" | "Box" => first_generic(seg)
.map(map_type)
.unwrap_or(DocFieldType::Object),
"Vec" => DocFieldType::Array {
elem: Box::new(
first_generic(seg)
.map(map_type)
.unwrap_or(DocFieldType::Object),
),
len: None,
},
_ => DocFieldType::Name(id),
}
}
syn::Type::Array(arr) => DocFieldType::Array {
elem: Box::new(map_type(&arr.elem)),
len: array_len(&arr.len),
},
syn::Type::Reference(r) => map_type(&r.elem),
_ => DocFieldType::Object,
}
}
fn first_generic(seg: &syn::PathSegment) -> Option<&syn::Type> {
let syn::PathArguments::AngleBracketed(args) = &seg.arguments else {
return None;
};
args.args.iter().find_map(|a| match a {
syn::GenericArgument::Type(t) => Some(t),
_ => None,
})
}
fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
if let syn::Type::Path(tp) = ty
&& let Some(seg) = tp.path.segments.last()
&& seg.ident == "Option"
{
return first_generic(seg);
}
None
}
fn array_len(expr: &syn::Expr) -> Option<usize> {
if let syn::Expr::Lit(l) = expr
&& let syn::Lit::Int(i) = &l.lit
{
return i.base10_parse::<usize>().ok();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn ty_of(src: &str) -> DocFieldType {
let file: syn::File = syn::parse_str(&format!("struct A {{ f: {src} }}")).expect("parse");
let syn::Item::Struct(s) = &file.items[0] else {
panic!("expected a struct");
};
map_type(&s.fields.iter().next().expect("one field").ty)
}
fn array(elem: DocFieldType, len: Option<usize>) -> DocFieldType {
DocFieldType::Array {
elem: Box::new(elem),
len,
}
}
#[test]
fn scalars_map_to_their_json_shapes() {
assert_eq!(ty_of("bool"), DocFieldType::Bool);
assert_eq!(ty_of("f32"), DocFieldType::Float);
assert_eq!(ty_of("u16"), DocFieldType::Integer);
assert_eq!(ty_of("String"), DocFieldType::Str);
assert_eq!(ty_of("serde_json::Value"), DocFieldType::Object);
}
#[test]
fn references_by_name_map_to_strings() {
assert_eq!(ty_of("AssetId"), DocFieldType::Str);
assert_eq!(ty_of("TextureHandle"), DocFieldType::Str);
}
#[test]
fn containers_unwrap_to_arrays_and_elements() {
assert_eq!(ty_of("Vec<f32>"), array(DocFieldType::Float, None));
assert_eq!(ty_of("[f32; 4]"), array(DocFieldType::Float, Some(4)));
assert_eq!(
ty_of("Vec<[f32; 2]>"),
array(array(DocFieldType::Float, Some(2)), None)
);
assert_eq!(ty_of("Box<bool>"), DocFieldType::Bool);
}
#[test]
fn an_unrecognised_ident_stays_unresolved() {
assert_eq!(
ty_of("PropCollider"),
DocFieldType::Name("PropCollider".to_string())
);
assert_eq!(
ty_of("Vec<WaterWave>"),
array(DocFieldType::Name("WaterWave".to_string()), None)
);
}
fn parse_types(src: &str) -> Vec<DocType> {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("a.rs"), src).expect("write");
std::fs::write(dir.path().join("notes.md"), "not rust").expect("write");
types(&[dir.path().to_path_buf()], &[]).expect("extract")
}
#[test]
fn a_struct_carries_its_doc_fields_and_defaults() {
let out = parse_types(
r#"
/// A prop.
///
/// More prose.
pub struct Prop {
/// The mesh
/// to draw.
pub mesh: String,
#[serde(rename = "type")]
pub kind: u32,
#[serde(skip)]
pub cached: u32,
pub collider: Option<PropCollider>,
}
impl Default for Prop {
fn default() -> Self {
Self { mesh: "cube".to_string(), kind: 3, collider: None }
}
}
"#,
);
assert_eq!(out.len(), 1);
let t = &out[0];
assert_eq!(t.name, "Prop");
assert_eq!(t.doc, "A prop.\n\nMore prose.");
let DocShape::Fields(fields) = &t.shape else {
panic!("expected fields");
};
assert_eq!(fields.len(), 3, "the skipped field is dropped");
assert_eq!(fields[0].key, "mesh");
assert_eq!(fields[0].doc, "The mesh to draw.");
assert_eq!(fields[0].default.as_deref(), Some("\"cube\""));
assert_eq!(fields[1].key, "type", "the serde rename is the key");
assert!(!fields[1].optional);
assert!(fields[2].optional);
assert_eq!(fields[2].default.as_deref(), Some("null"));
}
#[test]
fn a_string_enum_carries_its_renamed_values() {
let out = parse_types(
r#"
/// How to shade.
#[serde(rename_all = "snake_case")]
pub enum ShaderKind {
/// Per vertex.
VertexInstanced,
#[serde(rename = "frag")]
Fragment,
}
"#,
);
let DocShape::Values(values) = &out[0].shape else {
panic!("expected values");
};
assert_eq!(values[0].value, "vertex_instanced");
assert_eq!(values[0].doc, "Per vertex.");
assert_eq!(values[1].value, "frag");
}
#[test]
fn data_carrying_enums_and_tuple_structs_are_skipped() {
let out = parse_types(
"pub enum Shape { Circle(f32) }
pub struct Wrapper(u32);
pub struct Unit;",
);
assert!(out.is_empty());
}
#[test]
fn excluded_files_contribute_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let skipped = dir.path().join("skip.rs");
std::fs::write(&skipped, "pub struct Gone { pub a: u32 }").expect("write");
std::fs::write(dir.path().join("keep.rs"), "pub struct Kept { pub a: u32 }")
.expect("write");
let out = types(&[dir.path().to_path_buf()], &[skipped]).expect("extract");
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "Kept");
}
#[test]
fn types_come_back_sorted_by_name() {
let out = parse_types("pub struct Zeta { pub a: u32 } pub struct Alpha { pub a: u32 }");
let names: Vec<&str> = out.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["Alpha", "Zeta"]);
}
}