#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use proc_macro::TokenStream;
use quote::quote;
use syn::spanned::Spanned;
use syn::{
parse_macro_input, Attribute, Data, DataStruct, DeriveInput, Field, Fields, LitInt, LitStr,
Type, TypePath,
};
#[proc_macro_derive(Document, attributes(obj))]
pub fn derive_document(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match emit_impl(&input) {
Ok(ts) => ts.into(),
Err(err) => err.to_compile_error().into(),
}
}
fn emit_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let attrs = parse_struct_attrs(input)?;
if matches!(input.data, Data::Enum(_)) {
if !attrs.emit_schema {
return Err(syn::Error::new(
input.span(),
"#[derive(obj::Document)] on an enum requires `#[obj(schema)]` \
(or `#[obj(history(...))]`); an enum is never a Document itself",
));
}
return emit_schema_impl(input);
}
emit_struct_impl(input, &attrs)
}
fn emit_struct_impl(
input: &DeriveInput,
attrs: &StructAttrs,
) -> syn::Result<proc_macro2::TokenStream> {
let ident = &input.ident;
let collection = attrs
.collection
.clone()
.unwrap_or_else(|| ident.to_string());
let version: u32 = attrs.version.unwrap_or(1);
let mut index_specs = collect_field_indexes(input)?;
let composite_specs = validate_and_lift_composites(input, &attrs.composites)?;
index_specs.extend(composite_specs);
let indexes_body = emit_indexes_body(&index_specs);
let schema_impl = if attrs.emit_schema {
emit_schema_impl(input)?
} else {
proc_macro2::TokenStream::new()
};
let history_body = emit_history_body(&attrs.history);
let out = quote! {
#[automatically_derived]
impl ::obj::Document for #ident {
const COLLECTION: &'static str = #collection;
const VERSION: u32 = #version;
#indexes_body
#history_body
}
#schema_impl
};
Ok(out)
}
fn emit_schema_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let ident = &input.ident;
let body = match &input.data {
Data::Struct(_) => emit_schema_body_struct(input)?,
Data::Enum(data) => emit_schema_body_enum(data)?,
Data::Union(_) => {
return Err(syn::Error::new(
input.span(),
"#[derive(obj::Document)] does not support unions",
));
}
};
Ok(quote! {
#[automatically_derived]
impl ::obj::Schema for #ident {
fn schema() -> ::obj::DynamicSchema {
#body
}
}
})
}
fn emit_schema_body_struct(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let fields = named_fields(input)?;
let entries = fields
.iter()
.map(|f| {
let name = named_field_name(f)?;
let ty_schema = field_type_to_schema(&f.ty);
Ok(quote! { (::std::string::String::from(#name), #ty_schema) })
})
.collect::<syn::Result<Vec<_>>>()?;
Ok(quote! {
::obj::DynamicSchema::Map(::std::vec![ #( #entries ),* ])
})
}
fn named_field_name(field: &Field) -> syn::Result<String> {
field
.ident
.as_ref()
.map(ToString::to_string)
.ok_or_else(|| syn::Error::new(field.span(), "expected named field"))
}
fn emit_schema_body_enum(data: &syn::DataEnum) -> syn::Result<proc_macro2::TokenStream> {
let entries = data
.variants
.iter()
.enumerate()
.map(|(idx, v)| {
let discriminant = u32::try_from(idx).unwrap_or(u32::MAX);
let name = v.ident.to_string();
let payload = variant_payload_schema(&v.fields)?;
Ok(quote! {
::obj::EnumVariantSchema::new(
#discriminant,
#name,
#payload,
)
})
})
.collect::<syn::Result<Vec<_>>>()?;
Ok(quote! {
::obj::DynamicSchema::Enum(::std::vec![ #( #entries ),* ])
})
}
fn variant_payload_schema(fields: &Fields) -> syn::Result<proc_macro2::TokenStream> {
match fields {
Fields::Unit => Ok(quote! { ::obj::DynamicSchema::Null }),
Fields::Unnamed(unnamed) => {
let count = unnamed.unnamed.len();
if count == 1 {
let ty = &unnamed.unnamed[0].ty;
Ok(field_type_to_schema(ty))
} else {
let entries = unnamed.unnamed.iter().enumerate().map(|(i, f)| {
let key = i.to_string();
let ty_schema = field_type_to_schema(&f.ty);
quote! { (::std::string::String::from(#key), #ty_schema) }
});
Ok(quote! {
::obj::DynamicSchema::Map(::std::vec![ #( #entries ),* ])
})
}
}
Fields::Named(named) => {
let entries = named
.named
.iter()
.map(|f| {
let name = named_field_name(f)?;
let ty_schema = field_type_to_schema(&f.ty);
Ok(quote! { (::std::string::String::from(#name), #ty_schema) })
})
.collect::<syn::Result<Vec<_>>>()?;
Ok(quote! {
::obj::DynamicSchema::Map(::std::vec![ #( #entries ),* ])
})
}
}
}
fn emit_history_body(entries: &[HistoryAttr]) -> proc_macro2::TokenStream {
if entries.is_empty() {
return proc_macro2::TokenStream::new();
}
let mut sorted: Vec<&HistoryAttr> = entries.iter().collect();
sorted.sort_by_key(|h| h.version);
let items = sorted.iter().map(|h| {
let version = h.version;
let path = &h.ty_path;
quote! { (#version, <#path as ::obj::Schema>::schema()) }
});
quote! {
fn historical_schemas() -> ::std::vec::Vec<(u32, ::obj::DynamicSchema)> {
::std::vec![ #( #items ),* ]
}
}
}
fn field_type_to_schema(ty: &Type) -> proc_macro2::TokenStream {
if let Some(name) = scalar_schema_for(ty) {
let ident = quote::format_ident!("{name}");
return quote! { ::obj::DynamicSchema::#ident };
}
if let Some(inner) = vec_inner_type(ty) {
let inner_schema = field_type_to_schema(inner);
return quote! { ::obj::DynamicSchema::seq(#inner_schema) };
}
quote! { <#ty as ::obj::Schema>::schema() }
}
fn scalar_schema_for(ty: &Type) -> Option<&'static str> {
let Type::Path(TypePath { qself: None, path }) = ty else {
return None;
};
let segment = path.segments.last()?;
if !segment.arguments.is_none() {
return None;
}
let s = segment.ident.to_string();
match s.as_str() {
"bool" => Some("Bool"),
"u8" | "u16" | "u32" | "u64" | "usize" => Some("U64"),
"i8" | "i16" | "i32" | "i64" | "isize" => Some("I64"),
"f32" | "f64" => Some("F64"),
"String" => Some("String"),
_ => None,
}
}
fn vec_inner_type(ty: &Type) -> Option<&Type> {
let Type::Path(TypePath { qself: None, path }) = ty else {
return None;
};
let seg = path.segments.last()?;
if seg.ident != "Vec" {
return None;
}
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 validate_and_lift_composites(
input: &DeriveInput,
composites: &[CompositeAttr],
) -> syn::Result<Vec<IndexSpecEmit>> {
if composites.is_empty() {
return Ok(Vec::new());
}
let fields = named_fields(input)?;
let known: std::collections::HashSet<String> = fields
.iter()
.filter_map(|f| f.ident.as_ref().map(ToString::to_string))
.collect();
let mut out: Vec<IndexSpecEmit> = Vec::with_capacity(composites.len());
for c in composites {
if c.fields.len() < 2 {
return Err(syn::Error::new(c.span, "composite needs ≥ 2 fields"));
}
for field in &c.fields {
if !known.contains(field) {
return Err(syn::Error::new(
c.span,
format!("field '{field}' not declared on struct"),
));
}
}
let index_name = c.custom_name.clone().unwrap_or_else(|| c.fields.join("__"));
out.push(IndexSpecEmit {
kind: IndexKind::Composite(c.fields.clone()),
field_name: String::new(),
index_name,
});
}
Ok(out)
}
fn emit_indexes_body(specs: &[IndexSpecEmit]) -> proc_macro2::TokenStream {
if specs.is_empty() {
return proc_macro2::TokenStream::new();
}
let entries = specs.iter().map(IndexSpecEmit::emit);
quote! {
fn indexes() -> ::std::vec::Vec<::obj::IndexSpec> {
let mut out: ::std::vec::Vec<::obj::IndexSpec> = ::std::vec::Vec::new();
#(
if let ::std::result::Result::Ok(spec) = #entries {
out.push(spec);
}
)*
out
}
}
}
#[derive(Debug)]
struct CompositeAttr {
fields: Vec<String>,
custom_name: Option<String>,
span: proc_macro2::Span,
}
#[derive(Default, Debug)]
struct StructAttrs {
version: Option<u32>,
collection: Option<String>,
composites: Vec<CompositeAttr>,
history: Vec<HistoryAttr>,
emit_schema: bool,
}
#[derive(Debug)]
struct HistoryAttr {
version: u32,
ty_path: syn::Path,
}
fn parse_struct_attrs(input: &DeriveInput) -> syn::Result<StructAttrs> {
let mut acc = StructAttrs::default();
for attr in &input.attrs {
if !attr.path().is_ident("obj") {
continue;
}
parse_one_struct_attr(attr, &mut acc)?;
}
Ok(acc)
}
fn parse_one_struct_attr(attr: &Attribute, acc: &mut StructAttrs) -> syn::Result<()> {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("version") {
return parse_struct_version(&meta, acc);
}
if meta.path.is_ident("collection") {
return parse_struct_collection(&meta, acc);
}
if meta.path.is_ident("index_composite") {
let composite = parse_index_composite(&meta)?;
acc.composites.push(composite);
return Ok(());
}
if meta.path.is_ident("index") {
let composite = parse_struct_index_short(&meta)?;
acc.composites.push(composite);
return Ok(());
}
if meta.path.is_ident("history") {
parse_history(&meta, acc)?;
acc.emit_schema = true;
return Ok(());
}
if meta.path.is_ident("schema") {
if acc.emit_schema {
return Err(meta.error("`schema` declared twice or already implied by `history`"));
}
acc.emit_schema = true;
return Ok(());
}
Err(meta.error(
"unknown obj attribute (expected `version`, `collection`, `index`, `index_composite`, `history`, or `schema`)",
))
})
}
fn parse_struct_index_short(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<CompositeAttr> {
let span = meta.path.span();
let kind = parse_index_kind(meta)?;
match kind {
IndexKind::Composite(fields) => Ok(CompositeAttr {
fields,
custom_name: None,
span,
}),
_ => Err(syn::Error::new(
span,
"struct-level `index = ...` only accepts a tuple of field-name string literals \
(e.g. `index = (\"a\", \"b\")`); place `index`, `index = unique`, or `index = each` \
on a field instead",
)),
}
}
fn parse_history(meta: &syn::meta::ParseNestedMeta<'_>, acc: &mut StructAttrs) -> syn::Result<()> {
meta.parse_nested_meta(|inner| {
let ident = inner
.path
.get_ident()
.ok_or_else(|| inner.error("expected `vN = Type` key"))?;
let key = ident.to_string();
let version = parse_history_key(&key).ok_or_else(|| {
syn::Error::new(
ident.span(),
"history keys must be of the form `vN` (e.g. `v1`, `v2`, ...)",
)
})?;
if acc.history.iter().any(|h| h.version == version) {
return Err(syn::Error::new(
ident.span(),
format!("history key `v{version}` declared twice"),
));
}
let value = inner.value()?;
let ty_path: syn::Path = value.parse()?;
acc.history.push(HistoryAttr { version, ty_path });
Ok(())
})
}
fn parse_history_key(key: &str) -> Option<u32> {
let rest = key.strip_prefix('v')?;
rest.parse::<u32>().ok()
}
fn parse_struct_version(
meta: &syn::meta::ParseNestedMeta<'_>,
acc: &mut StructAttrs,
) -> syn::Result<()> {
if acc.version.is_some() {
return Err(meta.error("`version` declared twice"));
}
let value = meta.value()?;
let lit: LitInt = value.parse()?;
let n: u32 = lit
.base10_parse()
.map_err(|_| syn::Error::new(lit.span(), "expected unsigned integer for `version`"))?;
acc.version = Some(n);
Ok(())
}
fn parse_struct_collection(
meta: &syn::meta::ParseNestedMeta<'_>,
acc: &mut StructAttrs,
) -> syn::Result<()> {
if acc.collection.is_some() {
return Err(meta.error("`collection` declared twice"));
}
let value = meta.value()?;
let lit: LitStr = value.parse()?;
let s = lit.value();
if s.is_empty() {
return Err(syn::Error::new(
lit.span(),
"collection name must not be empty",
));
}
acc.collection = Some(s);
Ok(())
}
fn parse_index_composite(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<CompositeAttr> {
let span = meta.path.span();
let mut fields: Option<Vec<String>> = None;
let mut custom_name: Option<String> = None;
meta.parse_nested_meta(|inner| {
if inner.path.is_ident("fields") {
if fields.is_some() {
return Err(inner.error("`fields` declared twice"));
}
fields = Some(parse_composite_fields(&inner)?);
return Ok(());
}
if inner.path.is_ident("name") {
if custom_name.is_some() {
return Err(inner.error("`name` declared twice"));
}
let value = inner.value()?;
let lit: LitStr = value.parse()?;
let s = lit.value();
if s.is_empty() {
return Err(syn::Error::new(
lit.span(),
"composite index name must not be empty",
));
}
custom_name = Some(s);
return Ok(());
}
Err(inner.error("expected `fields = (...)` or `name = \"...\"`"))
})?;
let fields = fields.ok_or_else(|| {
syn::Error::new(
span,
"index_composite requires `fields = (\"a\", \"b\", ...)`",
)
})?;
Ok(CompositeAttr {
fields,
custom_name,
span,
})
}
fn parse_composite_fields(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<Vec<String>> {
let value = meta.value()?;
parse_composite_paren_paths(value)
}
#[derive(Debug, Clone)]
enum IndexKind {
Standard,
Unique,
Each,
Composite(Vec<String>),
}
#[derive(Debug)]
struct IndexSpecEmit {
kind: IndexKind,
field_name: String,
index_name: String,
}
impl IndexSpecEmit {
fn emit(&self) -> proc_macro2::TokenStream {
let name = &self.index_name;
match &self.kind {
IndexKind::Standard => self.emit_scalar(name, "e! { standard }),
IndexKind::Unique => self.emit_scalar(name, "e! { unique }),
IndexKind::Each => self.emit_scalar(name, "e! { each }),
IndexKind::Composite(paths) => Self::emit_composite(name, paths),
}
}
fn emit_scalar(&self, name: &str, ctor: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
let path = &self.field_name;
quote! {
::obj::IndexSpec::#ctor(
::std::string::String::from(#name),
::std::string::String::from(#path),
)
}
}
fn emit_composite(name: &str, paths: &[String]) -> proc_macro2::TokenStream {
let path_tokens = paths.iter().map(|p| quote! { #p });
quote! {
::obj::IndexSpec::composite(
::std::string::String::from(#name),
&[ #( #path_tokens ),* ],
)
}
}
}
fn collect_field_indexes(input: &DeriveInput) -> syn::Result<Vec<IndexSpecEmit>> {
let fields = named_fields(input)?;
let mut out: Vec<IndexSpecEmit> = Vec::new();
for field in fields {
for spec in parse_field_attrs(field)? {
out.push(spec);
}
}
Ok(out)
}
fn named_fields(
input: &DeriveInput,
) -> syn::Result<&syn::punctuated::Punctuated<Field, syn::Token![,]>> {
match &input.data {
Data::Struct(DataStruct {
fields: Fields::Named(named),
..
}) => Ok(&named.named),
_ => Err(syn::Error::new(
input.span(),
"#[derive(obj::Document)] only supports structs with named fields",
)),
}
}
fn parse_field_attrs(field: &Field) -> syn::Result<Vec<IndexSpecEmit>> {
let mut specs: Vec<IndexSpecEmit> = Vec::new();
let field_name = field
.ident
.as_ref()
.ok_or_else(|| syn::Error::new(field.span(), "expected named field"))?
.to_string();
for attr in &field.attrs {
if !attr.path().is_ident("obj") {
continue;
}
parse_one_field_attr(attr, field, &field_name, &mut specs)?;
}
Ok(specs)
}
fn parse_one_field_attr(
attr: &Attribute,
field: &Field,
field_name: &str,
specs: &mut Vec<IndexSpecEmit>,
) -> syn::Result<()> {
let mut kind: Option<IndexKind> = None;
let mut custom_name: Option<String> = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("index") {
if kind.is_some() {
return Err(meta.error("`index` declared twice on the same field"));
}
let parsed = parse_index_kind(&meta)?;
if matches!(parsed, IndexKind::Composite(_)) {
return Err(syn::Error::new(
meta.path.span(),
"tuple-form `index = (\"a\", \"b\")` is struct-level only; \
place it directly above the struct, not on a field",
));
}
kind = Some(parsed);
return Ok(());
}
if meta.path.is_ident("name") {
if custom_name.is_some() {
return Err(meta.error("`name` declared twice on the same field"));
}
let value = meta.value()?;
let lit: LitStr = value.parse()?;
let s = lit.value();
if s.is_empty() {
return Err(syn::Error::new(lit.span(), "index name must not be empty"));
}
custom_name = Some(s);
return Ok(());
}
Err(meta.error("unknown obj field attribute (expected `index`, `name`)"))
})?;
finalize_field_index(field, field_name, kind, custom_name, specs)
}
fn parse_index_kind(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<IndexKind> {
if !meta.input.peek(syn::Token![=]) {
return Ok(IndexKind::Standard);
}
let value = meta.value()?;
if value.peek(syn::token::Paren) {
let paths = parse_composite_paren_paths(value)?;
return Ok(IndexKind::Composite(paths));
}
let id: syn::Ident = value.parse().map_err(|_| {
syn::Error::new(
value.span(),
"expected one of: unique, each, or a tuple of field-name string literals like (\"a\", \"b\")",
)
})?;
if id == "unique" {
return Ok(IndexKind::Unique);
}
if id == "each" {
return Ok(IndexKind::Each);
}
Err(syn::Error::new(
id.span(),
"expected one of: unique, each (or omit `= ...` for a standard index)",
))
}
fn parse_composite_paren_paths(value: syn::parse::ParseStream<'_>) -> syn::Result<Vec<String>> {
const MAX_FIELDS: usize = 64;
let content;
syn::parenthesized!(content in value);
if content.is_empty() {
return Err(syn::Error::new(
content.span(),
"expected a tuple of field-name string literals, e.g. (\"a\", \"b\")",
));
}
let mut out: Vec<String> = Vec::new();
while !content.is_empty() {
if out.len() >= MAX_FIELDS {
return Err(syn::Error::new(
content.span(),
"too many composite-index fields (limit 64)",
));
}
let lit: LitStr = content.parse().map_err(|e| {
syn::Error::new(
e.span(),
"expected a tuple of field-name string literals, e.g. (\"a\", \"b\")",
)
})?;
let s = lit.value();
if s.is_empty() {
return Err(syn::Error::new(
lit.span(),
"composite field name must not be empty",
));
}
out.push(s);
if content.is_empty() {
break;
}
content.parse::<syn::Token![,]>()?;
}
Ok(out)
}
fn finalize_field_index(
field: &Field,
field_name: &str,
kind: Option<IndexKind>,
custom_name: Option<String>,
specs: &mut Vec<IndexSpecEmit>,
) -> syn::Result<()> {
let Some(kind) = kind else {
if custom_name.is_some() {
return Err(syn::Error::new(
field.span(),
"`#[obj(name = \"...\")]` requires an `index` declaration on the same field",
));
}
return Ok(());
};
if matches!(kind, IndexKind::Each) && !type_is_vec(&field.ty) {
return Err(syn::Error::new(
field.ty.span(),
"#[obj(index = each)] requires Vec<T>",
));
}
specs.push(IndexSpecEmit {
kind,
field_name: field_name.to_owned(),
index_name: custom_name.unwrap_or_else(|| field_name.to_owned()),
});
Ok(())
}
fn type_is_vec(ty: &Type) -> bool {
let Type::Path(TypePath { qself: None, path }) = ty else {
return false;
};
match path.segments.last() {
Some(seg) => seg.ident == "Vec",
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_str;
#[test]
fn typical_struct_expansion_is_under_200_lines() {
let input: DeriveInput = parse_str(
r#"
#[obj(version = 2, collection = "orders")]
#[obj(index_composite(fields = ("customer_id", "placed_at")))]
struct Order {
#[obj(index)]
customer_id: u64,
#[obj(index = unique)]
order_no: String,
#[obj(index = each)]
tags: Vec<String>,
placed_at: u64,
total_cents: u64,
}
"#,
)
.expect("parse typical struct");
let ts = emit_impl(&input).expect("emit");
let expanded = ts.to_string();
let line_count = expanded.lines().count();
let approx_lines = expanded.matches(';').count()
+ expanded.matches('{').count()
+ expanded.matches('}').count();
assert!(
approx_lines <= 200,
"expanded `#[derive(Document)]` exceeds 200-line budget: \
approx_lines = {approx_lines}; line_count = {line_count}; \
expansion = {expanded}",
);
}
#[test]
fn bare_derive_expansion_is_small() {
let input: DeriveInput = parse_str("struct Bare { x: u32 }").expect("parse");
let ts = emit_impl(&input).expect("emit");
let expanded = ts.to_string();
assert!(
!expanded.contains("fn indexes"),
"bare derive must NOT emit an indexes() override (expanded: {expanded})",
);
assert!(expanded.contains("COLLECTION"));
assert!(expanded.contains("VERSION"));
}
}