Skip to main content

ix_schema_derive/
lib.rs

1//! Derive macro for [`ix-schema`](https://docs.rs/ix-schema): turns a struct into a
2//! compile-time semantic `Manifest` (and, in later versions, a type-safe
3//! migration edge).
4//!
5//! The macro runs entirely at compile time. It parses the struct into an
6//! internal IR (`model::StructModel`), validates it, and lowers it to a
7//! `const MANIFEST` plus an `impl Ix`. Layout figures are emitted as
8//! `core::mem` calls so the compiler — not this macro — computes them, which
9//! makes the manifest provably consistent with the real layout.
10//!
11//! Doc links cannot reach the `ix-schema` crate here (`ix-schema` depends on this
12//! crate, not the reverse), so its types are referenced as plain code spans.
13#![deny(rustdoc::broken_intra_doc_links, rustdoc::private_intra_doc_links)]
14
15use proc_macro::TokenStream;
16use proc_macro_error2::{abort, proc_macro_error};
17use quote::quote;
18
19mod model;
20
21use model::{StructModel, VariantShape};
22
23/// Derive `ix_schema::Ix`, publishing a compile-time semantic manifest.
24///
25/// Works on **structs** (named, tuple, newtype, or unit — tuple positions become
26/// fields `"0"`, `"1"`, …) and **enums** — both fieldless (variants +
27/// discriminants) and data-carrying (variants + payload field types;
28/// variant-payload byte offsets are not modelled, since Rust gives no `const`
29/// access to them). Only unions are rejected.
30///
31/// # Attributes
32///
33/// Struct-level `#[ix(...)]`:
34/// * `version = N` — schema version (default `1`).
35/// * `migrate_from = M` — declares this version evolved from version `M`.
36///
37/// Field-level `#[ix(...)]`:
38/// * `since = N` — version the field was introduced in (default `1`).
39/// * `default = EXPR` — value for a field absent in the previous version.
40/// * `with = PATH` — function converting the predecessor's field.
41/// * `rename_from = "old"` — the field's name in the previous version.
42#[proc_macro_error]
43#[proc_macro_derive(Ix, attributes(ix))]
44pub fn derive_ix(input: TokenStream) -> TokenStream {
45    let input = syn::parse_macro_input!(input as syn::DeriveInput);
46    let model = StructModel::analyze(&input);
47    expand(&model).into()
48}
49
50/// Lower a validated `StructModel` to the `impl Ix` token stream.
51fn expand(model: &StructModel) -> proc_macro2::TokenStream {
52    let ident = &model.ident;
53    let version = model.version;
54    let repr = &model.repr;
55    let type_doc = &model.doc;
56    let (impl_generics, ty_generics, where_clause) = model.generics.split_for_impl();
57
58    let field_specs = model.fields.iter().map(|f| {
59        let name = &f.member;
60        let ty = &f.ty;
61        let since = f.since;
62        let doc = &f.doc;
63        quote! {
64            ::ix_schema::FieldSpec {
65                name: ::core::stringify!(#name),
66                doc: #doc,
67                type_name: ::core::stringify!(#ty),
68                offset: ::core::mem::offset_of!(Self, #name),
69                size: ::core::mem::size_of::<#ty>(),
70                align: ::core::mem::align_of::<#ty>(),
71                since: #since,
72            }
73        }
74    });
75
76    // A fieldless enum (all unit variants) can cast each variant to its integer
77    // discriminant; a data-carrying enum cannot, so its discriminants are `None`.
78    let enum_is_fieldless = !model.variants.is_empty()
79        && model
80            .variants
81            .iter()
82            .all(|v| matches!(v.kind, VariantShape::Unit));
83
84    let variant_specs = model.variants.iter().map(|v| {
85        let vname = &v.ident;
86        let vdoc = &v.doc;
87        let kind = match v.kind {
88            VariantShape::Unit => quote!(::ix_schema::VariantKind::Unit),
89            VariantShape::Tuple => quote!(::ix_schema::VariantKind::Tuple),
90            VariantShape::Struct => quote!(::ix_schema::VariantKind::Struct),
91        };
92        // `Self::Variant as i64` is compiler-computed, so it cannot disagree with
93        // the real discriminant; only valid when the whole enum is fieldless.
94        let discriminant = if enum_is_fieldless {
95            quote!(::core::option::Option::Some(Self::#vname as i64))
96        } else {
97            quote!(::core::option::Option::None)
98        };
99        // Payload fields: name (None for tuple positions), type, size, align — no
100        // offset, which Rust cannot evaluate in const for an enum variant.
101        let variant_fields = v.fields.iter().map(|f| {
102            let ty = &f.ty;
103            let name = match &f.name {
104                Some(id) => quote!(::core::option::Option::Some(::core::stringify!(#id))),
105                None => quote!(::core::option::Option::None),
106            };
107            quote! {
108                ::ix_schema::VariantFieldSpec {
109                    name: #name,
110                    type_name: ::core::stringify!(#ty),
111                    size: ::core::mem::size_of::<#ty>(),
112                    align: ::core::mem::align_of::<#ty>(),
113                }
114            }
115        });
116        quote! {
117            ::ix_schema::VariantSpec {
118                name: ::core::stringify!(#vname),
119                doc: #vdoc,
120                discriminant: #discriminant,
121                kind: #kind,
122                fields: &[ #(#variant_fields),* ],
123            }
124        }
125    });
126
127    let (evolution_expr, migration_impl) = match &model.migrate_from {
128        None => (quote!(::ix_schema::EvolutionSpec::GENESIS), quote!()),
129        Some(prev_ty) => {
130            let changes = model.fields.iter().filter_map(field_change);
131            let removed = model
132                .removed
133                .iter()
134                .map(|name| quote!(::ix_schema::FieldChange::Removed { name: #name }));
135            let inits = model.fields.iter().map(field_init);
136            let evolution = quote! {
137                ::ix_schema::EvolutionSpec {
138                    migrates_from: ::core::option::Option::Some(
139                        <#prev_ty as ::ix_schema::Ix>::MANIFEST.schema_version
140                    ),
141                    changes: &[ #(#changes,)* #(#removed),* ],
142                }
143            };
144            let migration = quote! {
145                // Type-safe guard: the predecessor must be an older schema.
146                const _: () = ::core::assert!(
147                    <#prev_ty as ::ix_schema::Ix>::MANIFEST.schema_version < #version,
148                    "ix-schema: `migrate_from` target must be an older schema version",
149                );
150
151                impl #impl_generics ::ix_schema::MigrateFrom<#prev_ty> for #ident #ty_generics
152                #where_clause {
153                    fn migrate_from(prev: #prev_ty) -> Self {
154                        Self { #(#inits),* }
155                    }
156                }
157            };
158            (evolution, migration)
159        }
160    };
161
162    let ix_impl = quote! {
163        impl #impl_generics ::ix_schema::Ix for #ident #ty_generics #where_clause {
164            const MANIFEST: ::ix_schema::Manifest<'static> = ::ix_schema::Manifest {
165                type_name: ::core::concat!(::core::module_path!(), "::", ::core::stringify!(#ident)),
166                doc: #type_doc,
167                schema_version: #version,
168                layout: ::ix_schema::LayoutSpec {
169                    size: ::core::mem::size_of::<Self>(),
170                    align: ::core::mem::align_of::<Self>(),
171                    repr: #repr,
172                },
173                fields: &[ #(#field_specs),* ],
174                variants: &[ #(#variant_specs),* ],
175                evolution: #evolution_expr,
176            };
177        }
178    };
179
180    quote! {
181        #ix_impl
182        #migration_impl
183    }
184}
185
186/// The constructor expression for one field of the migrated struct.
187///
188/// The four cases are exhaustive and each is checked by the type system:
189/// a `default` field never touches `prev`; `with`/`rename_from`/carry-over all
190/// reference `prev`, so a wrong type or missing field is a compile error.
191fn field_init(field: &model::FieldModel) -> proc_macro2::TokenStream {
192    let name = &field.member;
193    if let Some(default) = &field.default {
194        quote!(#name: #default)
195    } else if let Some(with) = &field.with {
196        quote!(#name: #with(prev.#name))
197    } else if let Some(old) = &field.rename_from {
198        let old = syn::Ident::new(old, field.span);
199        quote!(#name: prev.#old)
200    } else {
201        quote!(#name: prev.#name)
202    }
203}
204
205/// The `FieldChange` entry a field contributes to the evolution record, if any.
206fn field_change(field: &model::FieldModel) -> Option<proc_macro2::TokenStream> {
207    let name = &field.member;
208    if field.with.is_some() {
209        Some(quote!(::ix_schema::FieldChange::Transformed {
210            name: ::core::stringify!(#name)
211        }))
212    } else if let Some(old) = &field.rename_from {
213        Some(
214            quote!(::ix_schema::FieldChange::Renamed { from: #old, to: ::core::stringify!(#name) }),
215        )
216    } else if field.default.is_some() {
217        Some(quote!(::ix_schema::FieldChange::Added {
218            name: ::core::stringify!(#name)
219        }))
220    } else {
221        None
222    }
223}
224
225/// Abort compilation if the same key appears twice in an attribute list.
226fn reject_duplicate(seen: bool, meta: &syn::meta::ParseNestedMeta) -> syn::Result<()> {
227    if seen {
228        return Err(meta.error("duplicate `ix` attribute key"));
229    }
230    Ok(())
231}
232
233/// Consume and ignore a parenthesised group after a meta path (e.g. `align(8)`).
234fn skip_optional_parens(meta: &syn::meta::ParseNestedMeta) -> syn::Result<()> {
235    if meta.input.peek(syn::token::Paren) {
236        let content;
237        syn::parenthesized!(content in meta.input);
238        let _: proc_macro2::TokenStream = content.parse()?;
239    }
240    Ok(())
241}
242
243/// Translate the `#[repr(..)]` attributes into a `::ix_schema::Repr` expression.
244fn parse_repr(attrs: &[syn::Attribute]) -> proc_macro2::TokenStream {
245    use proc_macro_error2::ResultExt as _;
246
247    let mut repr = quote!(::ix_schema::Repr::Rust);
248    for attr in attrs {
249        if !attr.path().is_ident("repr") {
250            continue;
251        }
252        attr.parse_nested_meta(|meta| {
253            if meta.path.is_ident("C") {
254                repr = quote!(::ix_schema::Repr::C);
255            } else if meta.path.is_ident("transparent") {
256                repr = quote!(::ix_schema::Repr::Transparent);
257            } else if meta.path.is_ident("packed") {
258                if meta.input.peek(syn::token::Paren) {
259                    let content;
260                    syn::parenthesized!(content in meta.input);
261                    let n: syn::LitInt = content.parse()?;
262                    let n: usize = n.base10_parse()?;
263                    repr = quote!(::ix_schema::Repr::Packed(#n));
264                } else {
265                    repr = quote!(::ix_schema::Repr::Packed(1));
266                }
267            } else {
268                // align(n) and primitive enum reprs don't change struct identity
269                // for our purposes; consume any payload and ignore.
270                skip_optional_parens(&meta)?;
271            }
272            Ok(())
273        })
274        .unwrap_or_abort();
275    }
276    repr
277}
278
279/// Emit precise guidance on an item shape ix cannot model. Structs (all shapes)
280/// and enums are handled elsewhere; only unions reach here.
281fn abort_unsupported(input: &syn::DeriveInput) -> ! {
282    match &input.data {
283        syn::Data::Union(_) => abort!(
284            input.ident,
285            "`#[derive(Ix)]` supports structs and enums, not unions";
286            help = "a union's fields overlap in memory; model it as a struct with a tag field"
287        ),
288        // Structs and enums are dispatched to their analysers; these arms only
289        // trigger if a future shape reaches here.
290        _ => abort!(input.ident, "`#[derive(Ix)]` could not model this type"),
291    }
292}