Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,24 @@ is set, `Field::get_name` and `Variant::get_name` apply it via `format_case`, so
`settings()`, and validation paths agree with what serde accepts. Env keys deliberately skip the
casing and stay derived from the Rust name (or an explicit `rename`), matching production.

Generics are supported by `Schematic` but not `Config` (the partial type isn't generic), which
matches production. As in production, `#[derive(Schematic)]` on a generic type does not add a
`T: Schematic` bound for you — write it yourself. Unlike production, a generic type's `schema_name`
appends each type argument (`Wrapper<String>` becomes `WrapperString`), because schemas are keyed by
name alone and every instantiation would otherwise claim the same one.
**All three derives support generics**, which is a divergence from production (where only
`Schematic` does). `Container` carries `input.generics`, and every emitted item threads them
through: the partial declaration, its `Default`/`Deserialize`, `PartialConfig`, `Config`, and both
`Schematic` impls. Three things fall out of that:

- No bounds are added for you. `PartialConfig` requires `Clone + Default + DeserializeOwned +
Schematic + Serialize`, so a generic `Config` needs those on its own type parameters, and a
generic `Schematic` needs `T: Schematic`. An under-bounded type fails to compile at the impl.
- The partial gets an explicit `#[serde(bound(deserialize = "T: DeserializeOwned"))]`. Without it
serde infers `T: Deserialize<'de>`, which is ambiguous against the `DeserializeOwned` the where
clause carries. `#[config(partial(serde(bound(...))))]` suppresses the generated one.
- A generic type's `schema_name` appends each type argument (`Wrapper<String>` becomes
`WrapperString`, its partial `PartialWrapperString`), because schemas are keyed by name alone and
every instantiation would otherwise claim the same one.

A generic `ConfigEnum` only makes sense with a `fallback`, since unit variants carry no data. Its
`Display` writes each arm for itself, so the fallback goes through `T: Display` instead of having to
be a `&str`.

When implementing something in `core`, the old implementation in `crates/macros` is the reference.
It is _not_ always correct — this session found many bugs in it — but it tells you the intended
Expand Down
17 changes: 14 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

## Next

This is a major release that has been in development for over a year. The macro layer has been
rewritten from the ground up, utilizing new patterns to improve maintainability and extendability,
while the schema types have been updated to be more flexible and composable.

#### 💥 Breaking

##### Config

- Structs will no longer default to `camelCase` field name casing.
- Enums will no longer default to `kebab-case` variant name casing.
- Removed `#[config(serde(...))]` on containers. Use `#[serde(...)]` instead.
- Removed `#[config(serde(...))]` on containers. Use `#[serde(...)]` directly instead.
- Removed `#[variant(value)]` on enum variants. Use `#[variant(rename)]` instead, which does the
same thing.
- Removed the `tracing` Cargo feature, which wrapped generated code in `#[tracing::instrument]`.
The loader is still instrumented; only the derive output no longer is.
- Removed the `tracing` Cargo feature, which wrapped generated code in `#[tracing::instrument]`. The
loader is still instrumented; only the derive output no longer is.

##### Schema

Expand Down Expand Up @@ -64,6 +68,13 @@
- Updated `#[setting(extend)]` settings to support `Option` wrapped values.
- Updated the methods of `PartialConfig` to all have a default implementation. This helps to greatly
reduce the amount of macro generated code.
- Added generics support to `#[derive(Config)]` and `#[derive(ConfigEnum)]`, which previously only
worked on `#[derive(Schematic)]`. Type arguments are carried into the partial type and every
generated implementation, and a generic type's schema name appends them, so `Wrapper<String>` and
`Wrapper<usize>` no longer collide.
- Bounds are not inferred. A generic `Config` needs
`Clone + Default + DeserializeOwned + Schematic + Serialize` on its type parameters, since
`PartialConfig` requires them.
- Improved the parse, handling, and validation of container and field attributes.
- Updated `#[config(before_parse)]` on `ConfigEnum` to accept every case that `rename_all` does,
instead of only `lowercase` and `UPPERCASE`. Incoming values are normalized before being matched,
Expand Down
109 changes: 91 additions & 18 deletions crates/core/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,29 @@ impl Container {
self.ident.to_string()
}

/// Generics for a type *declaration*, which keeps bounds and defaults.
/// The where clause is returned separately, as its position differs
/// between named and tuple shapes.
pub fn get_declaration_generics(&self) -> (&Generics, Option<&syn::WhereClause>) {
(&self.generics, self.generics.where_clause.as_ref())
}

/// The same generics with a `'de` lifetime prepended, for a hand written
/// `Deserialize` implementation.
pub fn get_deserialize_generics(&self) -> Generics {
let mut generics = self.generics.clone();

generics.params.insert(
0,
syn::GenericParam::Lifetime(syn::LifetimeParam::new(syn::Lifetime::new(
"'de",
proc_macro2::Span::call_site(),
))),
);

generics
}

pub fn is_config_enum(&self) -> bool {
matches!(self.macro_type, ContainerMacro::ConfigUnitEnum)
}
Expand Down Expand Up @@ -200,6 +223,36 @@ impl Container {
attrs
}

/// `PartialConfig` requires the partial to be `DeserializeOwned`, which
/// means every type argument must be too. Serde would otherwise infer a
/// `T: Deserialize<'de>` bound of its own, and the two are ambiguous when
/// both are in scope, so the bound is stated outright.
///
/// Returns `None` when there is nothing generic to bound, or when the
/// user supplied their own bound through `partial(serde(...))`.
fn get_partial_deserialize_bound(&self) -> Option<String> {
let params = self
.generics
.type_params()
.map(|param| format!("{}: serde::de::DeserializeOwned", param.ident))
.collect::<Vec<_>>();

if params.is_empty() || self.has_partial_serde_bound() {
return None;
}

Some(params.join(", "))
}

fn has_partial_serde_bound(&self) -> bool {
self.args.partial.as_ref().is_some_and(|partial| {
partial
.get_attributes()
.iter()
.any(|attr| attr.to_string().contains("bound"))
})
}

pub fn get_partial_serde_attribute_args(&self) -> TokenStream {
let mut meta = vec![];

Expand Down Expand Up @@ -235,6 +288,10 @@ impl Container {
meta.push(quote! { expecting = #expecting });
}

if let Some(bound) = self.get_partial_deserialize_bound() {
meta.push(quote! { bound(deserialize = #bound) });
}

// Config attributes take precedence over serde attributes
let renames = [
("rename", &self.args.rename, &self.serde_args.rename),
Expand Down Expand Up @@ -271,18 +328,19 @@ impl Container {

let from_partial_method = self.impl_full_from_partial();
let settings_method = self.impl_full_settings();
let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl();

quote! {
#[automatically_derived]
impl schematic::Config for #base_name {
type Partial = #partial_name;
impl #impl_generics schematic::Config for #base_name #ty_generics #where_clause {
type Partial = #partial_name #ty_generics;

#from_partial_method
#settings_method
}

#[automatically_derived]
impl Default for #base_name {
impl #impl_generics Default for #base_name #ty_generics #where_clause {
fn default() -> Self {
<Self as schematic::Config>::from_partial(
<Self as schematic::Config>::default_partial()
Expand Down Expand Up @@ -423,21 +481,23 @@ impl Container {
let partial_name = self.get_partial_ident();
let attrs = self.get_partial_attributes();
let vis = &self.vis;
let (generics, where_clause) = self.get_declaration_generics();

match &self.inner {
ContainerInner::NamedStruct { fields } => quote! {
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
#(#attrs)*
#vis struct #partial_name {
#vis struct #partial_name #generics #where_clause {
#(#fields)*
}
},
// A tuple struct takes its where clause after the fields
ContainerInner::UnnamedStruct { fields } => quote! {
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
#(#attrs)*
#vis struct #partial_name(
#vis struct #partial_name #generics (
#(#fields)*
);
) #where_clause;
},
ContainerInner::UnnamedEnum { variants } | ContainerInner::UnitEnum { variants } => {
// Untagged enums implement `Deserialize` manually,
Expand All @@ -451,7 +511,7 @@ impl Container {
quote! {
#[derive(#derives)]
#(#attrs)*
#vis enum #partial_name {
#vis enum #partial_name #generics #where_clause {
#(#variants)*
}
}
Expand Down Expand Up @@ -480,9 +540,11 @@ impl Container {
let partial_name = self.get_partial_ident();
let value = default_variant.impl_partial_default_value().value;

let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl();

quote! {
#[automatically_derived]
impl Default for #partial_name {
impl #impl_generics Default for #partial_name #ty_generics #where_clause {
fn default() -> Self {
Self::#value
}
Expand All @@ -509,9 +571,13 @@ impl Container {
}
}

let de_generics = self.get_deserialize_generics();
let (de_impl_generics, _, _) = de_generics.split_for_impl();
let (_, ty_generics, where_clause) = self.generics.split_for_impl();

quote! {
#[automatically_derived]
impl<'de> serde::Deserialize<'de> for #partial_name {
impl #de_impl_generics serde::Deserialize<'de> for #partial_name #ty_generics #where_clause {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
Expand Down Expand Up @@ -652,9 +718,9 @@ impl Container {
#[automatically_derived]
impl #impl_generics std::fmt::Display for #name #ty_generics #where_clause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", match self {
match self {
#(#display_arms)*
})
}
}
}
}
Expand Down Expand Up @@ -695,7 +761,11 @@ impl Container {
/// every instantiation would otherwise claim the same one.
#[cfg(feature = "schema")]
pub fn impl_schematic_name(&self) -> TokenStream {
let base_name_string = self.get_name();
self.impl_schematic_name_for(self.get_name())
}

#[cfg(feature = "schema")]
fn impl_schematic_name_for(&self, base_name_string: String) -> TokenStream {
let params = self
.generics
.type_params()
Expand Down Expand Up @@ -748,10 +818,11 @@ impl Container {
#[cfg(not(feature = "schema"))]
pub fn impl_schematic_partial(&self) -> TokenStream {
let partial_name = self.get_partial_ident();
let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl();

quote! {
#[automatically_derived]
impl schematic::Schematic for #partial_name {}
impl #impl_generics schematic::Schematic for #partial_name #ty_generics #where_clause {}
}
}

Expand All @@ -761,17 +832,18 @@ impl Container {
pub fn impl_schematic_partial(&self) -> TokenStream {
let base_name = &self.ident;
let partial_name = self.get_partial_ident();
let partial_name_string = partial_name.to_string();
let partial_schema_name = self.impl_schematic_name_for(partial_name.to_string());
let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl();

quote! {
#[automatically_derived]
impl schematic::Schematic for #partial_name {
impl #impl_generics schematic::Schematic for #partial_name #ty_generics #where_clause {
fn schema_name() -> Option<String> {
Some(#partial_name_string.into())
#partial_schema_name
}

fn build_schema(schema: schematic::SchemaBuilder) -> schematic::Schema {
let mut schema = #base_name::build_schema(schema);
let mut schema = <#base_name #ty_generics as schematic::Schematic>::build_schema(schema);
schematic::internal::partialize_schema(&mut schema, true);
schema
}
Expand Down Expand Up @@ -891,10 +963,11 @@ impl Container {
let finalize_method = self.impl_partial_finalize();
let merge_method = self.impl_partial_merge();
let validate_method = self.impl_partial_validate();
let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl();

quote! {
#[automatically_derived]
impl schematic::PartialConfig for #partial_name {
impl #impl_generics schematic::PartialConfig for #partial_name #ty_generics #where_clause {
type Context = #context;

#default_values_method
Expand Down
6 changes: 4 additions & 2 deletions crates/core/src/variant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,18 @@ impl Variant {
}

/// A match arm that formats this variant back into its string value.
/// Each arm writes for itself, so that a fallback holding a generic type
/// can go through `Display` rather than having to be a `&str`.
pub fn impl_config_enum_display(&self) -> TokenStream {
let name = &self.ident;

if self.is_fallback() {
return quote! { Self::#name(fallback) => fallback, };
return quote! { Self::#name(fallback) => write!(f, "{fallback}"), };
}

let value = self.get_name();

quote! { Self::#name => #value, }
quote! { Self::#name => f.write_str(#value), }
}

/// A match arm that parses a string into this variant. A fallback absorbs
Expand Down
Loading
Loading