diff --git a/Cargo.lock b/Cargo.lock index c10f94a1d..2a1f148ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -548,6 +548,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2993,6 +3002,7 @@ dependencies = [ name = "stackable-versioned-macros" version = "0.1.0" dependencies = [ + "convert_case", "darling", "itertools 0.13.0", "k8s-version", @@ -3503,6 +3513,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "unicode-xid" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 2b857392b..9ee3293f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ chrono = { version = "0.4.38", default-features = false } clap = { version = "4.5.4", features = ["derive", "cargo", "env"] } const_format = "0.2.32" const-oid = "0.9.6" +convert_case = "0.6.0" darling = "0.20.9" delegate = "0.12.0" derivative = "2.2.0" diff --git a/crates/stackable-versioned-macros/Cargo.toml b/crates/stackable-versioned-macros/Cargo.toml index 13a5a18e7..5428289d9 100644 --- a/crates/stackable-versioned-macros/Cargo.toml +++ b/crates/stackable-versioned-macros/Cargo.toml @@ -12,6 +12,7 @@ proc-macro = true [dependencies] k8s-version = { path = "../k8s-version", features = ["darling"] } +convert_case.workspace = true darling.workspace = true itertools.workspace = true proc-macro2.workspace = true diff --git a/crates/stackable-versioned-macros/src/attrs/container.rs b/crates/stackable-versioned-macros/src/attrs/common/container.rs similarity index 94% rename from crates/stackable-versioned-macros/src/attrs/container.rs rename to crates/stackable-versioned-macros/src/attrs/common/container.rs index 918b81042..94c1444b4 100644 --- a/crates/stackable-versioned-macros/src/attrs/container.rs +++ b/crates/stackable-versioned-macros/src/attrs/common/container.rs @@ -49,7 +49,13 @@ impl ContainerAttributes { .sort_by(|lhs, rhs| lhs.name.partial_cmp(&rhs.name).unwrap_or(Ordering::Equal)); for (index, version) in original.iter().enumerate() { - if version.name == self.versions.get(index).unwrap().name { + if version.name + == self + .versions + .get(index) + .expect("internal error: version at that index must exist") + .name + { continue; } diff --git a/crates/stackable-versioned-macros/src/attrs/common/item.rs b/crates/stackable-versioned-macros/src/attrs/common/item.rs new file mode 100644 index 000000000..15b2885da --- /dev/null +++ b/crates/stackable-versioned-macros/src/attrs/common/item.rs @@ -0,0 +1,84 @@ +use darling::{util::SpannedValue, Error, FromMeta}; +use k8s_version::Version; +use proc_macro2::Span; +use syn::Path; + +/// These attributes are meant to be used in super structs, which add +/// [`Field`](syn::Field) or [`Variant`](syn::Variant) specific attributes via +/// darling's flatten feature. This struct only provides shared attributes. +#[derive(Debug, FromMeta)] +#[darling(and_then = ItemAttributes::validate)] +pub(crate) struct ItemAttributes { + /// This parses the `added` attribute on items (fields or variants). It can + /// only be present at most once. + pub(crate) added: Option, + + /// This parses the `renamed` attribute on items (fields or variants). It + /// can be present 0..n times. + #[darling(multiple, rename = "renamed")] + pub(crate) renames: Vec, + + /// This parses the `deprecated` attribute on items (fields or variants). It + /// can only be present at most once. + pub(crate) deprecated: Option, +} + +impl ItemAttributes { + fn validate(self) -> Result { + // Validate deprecated options + + // TODO (@Techassi): Make the field 'note' optional, because in the + // future, the macro will generate parts of the deprecation note + // automatically. The user-provided note will then be appended to the + // auto-generated one. + + if let Some(deprecated) = &self.deprecated { + if deprecated.note.is_empty() { + return Err(Error::custom("deprecation note must not be empty") + .with_span(&deprecated.note.span())); + } + } + + Ok(self) + } +} + +/// For the added() action +/// +/// Example usage: +/// - `added(since = "...")` +/// - `added(since = "...", default_fn = "custom_fn")` +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct AddedAttributes { + pub(crate) since: SpannedValue, + + #[darling(rename = "default", default = "default_default_fn")] + pub(crate) default_fn: SpannedValue, +} + +fn default_default_fn() -> SpannedValue { + SpannedValue::new( + syn::parse_str("std::default::Default::default").expect("internal error: path must parse"), + Span::call_site(), + ) +} + +/// For the renamed() action +/// +/// Example usage: +/// - `renamed(since = "...", from = "...")` +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct RenamedAttributes { + pub(crate) since: SpannedValue, + pub(crate) from: SpannedValue, +} + +/// For the deprecated() action +/// +/// Example usage: +/// - `deprecated(since = "...", note = "...")` +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct DeprecatedAttributes { + pub(crate) since: SpannedValue, + pub(crate) note: SpannedValue, +} diff --git a/crates/stackable-versioned-macros/src/attrs/common/mod.rs b/crates/stackable-versioned-macros/src/attrs/common/mod.rs new file mode 100644 index 000000000..175e8eed5 --- /dev/null +++ b/crates/stackable-versioned-macros/src/attrs/common/mod.rs @@ -0,0 +1,5 @@ +mod container; +mod item; + +pub(crate) use container::*; +pub(crate) use item::*; diff --git a/crates/stackable-versioned-macros/src/attrs/field.rs b/crates/stackable-versioned-macros/src/attrs/field.rs index 31453f2ce..94851a8d1 100644 --- a/crates/stackable-versioned-macros/src/attrs/field.rs +++ b/crates/stackable-versioned-macros/src/attrs/field.rs @@ -1,9 +1,10 @@ -use darling::{util::SpannedValue, Error, FromField, FromMeta}; -use k8s_version::Version; -use proc_macro2::Span; -use syn::{Field, Ident, Path}; +use darling::{Error, FromField}; +use syn::{Field, Ident}; -use crate::{attrs::container::ContainerAttributes, consts::DEPRECATED_PREFIX}; +use crate::{ + attrs::common::{ContainerAttributes, ItemAttributes}, + consts::DEPRECATED_FIELD_PREFIX, +}; /// This struct describes all available field attributes, as well as the field /// name to display better diagnostics. @@ -29,43 +30,24 @@ use crate::{attrs::container::ContainerAttributes, consts::DEPRECATED_PREFIX}; and_then = FieldAttributes::validate )] pub(crate) struct FieldAttributes { - pub(crate) ident: Option, - pub(crate) added: Option, - - #[darling(multiple, rename = "renamed")] - pub(crate) renames: Vec, - - pub(crate) deprecated: Option, -} - -#[derive(Clone, Debug, FromMeta)] -pub(crate) struct AddedAttributes { - pub(crate) since: SpannedValue, - - #[darling(rename = "default", default = "default_default_fn")] - pub(crate) default_fn: SpannedValue, -} - -fn default_default_fn() -> SpannedValue { - SpannedValue::new( - syn::parse_str("std::default::Default::default").expect("internal error: path must parse"), - Span::call_site(), - ) -} - -#[derive(Clone, Debug, FromMeta)] -pub(crate) struct RenamedAttributes { - pub(crate) since: SpannedValue, - pub(crate) from: SpannedValue, -} + #[darling(flatten)] + pub(crate) common: ItemAttributes, -#[derive(Clone, Debug, FromMeta)] -pub(crate) struct DeprecatedAttributes { - pub(crate) since: SpannedValue, - pub(crate) note: SpannedValue, + // The ident (automatically extracted by darling) cannot be moved into the + // shared item attributes because for struct fields, the type is + // `Option`, while for enum variants, the type is `Ident`. + pub(crate) ident: Option, } impl FieldAttributes { + // NOTE (@Techassi): Ideally, these validations should be moved to the + // ItemAttributes impl, because common validation like action combinations + // and action order can be validated without taking the type of attribute + // into account (field vs variant). However, we would loose access to the + // field / variant ident and as such, cannot display the error directly on + // the affected field / variant. This is a significant decrease in DX. + // See https://github.com/TedDriggs/darling/discussions/294 + /// This associated function is called by darling (see and_then attribute) /// after it successfully parsed the attribute. This allows custom /// validation of the attribute which extends the validation already in @@ -80,12 +62,6 @@ impl FieldAttributes { errors.handle(self.validate_action_order()); errors.handle(self.validate_field_name()); - // Code quality validation - errors.handle(self.validate_deprecated_options()); - - // TODO (@Techassi): Add validation for renames so that renamed fields - // match up and form a continous chain (eg. foo -> bar -> baz). - // TODO (@Techassi): Add hint if a field is added in the first version // that it might be clever to remove the 'added' attribute. @@ -107,7 +83,11 @@ impl FieldAttributes { /// - `renamed` and `deprecated` using the same version: Again, the same /// rules from above apply here as well. fn validate_action_combinations(&self) -> Result<(), Error> { - match (&self.added, &self.renames, &self.deprecated) { + match ( + &self.common.added, + &self.common.renames, + &self.common.deprecated, + ) { (Some(added), _, Some(deprecated)) if *added.since == *deprecated.since => { Err(Error::custom( "field cannot be marked as `added` and `deprecated` in the same version", @@ -145,15 +125,15 @@ impl FieldAttributes { /// - All `renamed` actions must use a greater version than `added` but a /// lesser version than `deprecated`. fn validate_action_order(&self) -> Result<(), Error> { - let added_version = self.added.as_ref().map(|a| *a.since); - let deprecated_version = self.deprecated.as_ref().map(|d| *d.since); + let added_version = self.common.added.as_ref().map(|a| *a.since); + let deprecated_version = self.common.deprecated.as_ref().map(|d| *d.since); // First, validate that the added version is less than the deprecated // version. // NOTE (@Techassi): Is this already covered by the code below? if let (Some(added_version), Some(deprecated_version)) = (added_version, deprecated_version) { - if added_version >= deprecated_version { + if added_version > deprecated_version { return Err(Error::custom(format!( "field was marked as `added` in version `{added_version}` while being marked as `deprecated` in an earlier version `{deprecated_version}`" )).with_span(&self.ident)); @@ -162,7 +142,7 @@ impl FieldAttributes { // Now, iterate over all renames and ensure that their versions are // between the added and deprecated version. - if !self.renames.iter().all(|r| { + if !self.common.renames.iter().all(|r| { added_version.map_or(true, |a| a < *r.since) && deprecated_version.map_or(true, |d| d > *r.since) }) { @@ -185,20 +165,20 @@ impl FieldAttributes { /// in their name. The prefix must not be included for fields which are /// not deprecated. fn validate_field_name(&self) -> Result<(), Error> { - let starts_with = self + let starts_with_deprecated = self .ident .as_ref() - .unwrap() + .expect("internal error: to be validated fields must have a name") .to_string() - .starts_with(DEPRECATED_PREFIX); + .starts_with(DEPRECATED_FIELD_PREFIX); - if self.deprecated.is_some() && !starts_with { + if self.common.deprecated.is_some() && !starts_with_deprecated { return Err(Error::custom( "field was marked as `deprecated` and thus must include the `deprecated_` prefix in its name" ).with_span(&self.ident)); } - if self.deprecated.is_none() && starts_with { + if self.common.deprecated.is_none() && starts_with_deprecated { return Err(Error::custom( "field includes the `deprecated_` prefix in its name but is not marked as `deprecated`" ).with_span(&self.ident)); @@ -207,22 +187,6 @@ impl FieldAttributes { Ok(()) } - fn validate_deprecated_options(&self) -> Result<(), Error> { - // TODO (@Techassi): Make the field 'note' optional, because in the - // future, the macro will generate parts of the deprecation note - // automatically. The user-provided note will then be appended to the - // auto-generated one. - - if let Some(deprecated) = &self.deprecated { - if deprecated.note.is_empty() { - return Err(Error::custom("deprecation note must not be empty") - .with_span(&deprecated.note.span())); - } - } - - Ok(()) - } - /// Validates that each field action version is present in the declared /// container versions. pub(crate) fn validate_versions( @@ -233,7 +197,7 @@ impl FieldAttributes { // NOTE (@Techassi): Can we maybe optimize this a little? let mut errors = Error::accumulator(); - if let Some(added) = &self.added { + if let Some(added) = &self.common.added { if !container_attrs .versions .iter() @@ -246,7 +210,7 @@ impl FieldAttributes { } } - for rename in &self.renames { + for rename in &self.common.renames { if !container_attrs .versions .iter() @@ -259,7 +223,7 @@ impl FieldAttributes { } } - if let Some(deprecated) = &self.deprecated { + if let Some(deprecated) = &self.common.deprecated { if !container_attrs .versions .iter() diff --git a/crates/stackable-versioned-macros/src/attrs/mod.rs b/crates/stackable-versioned-macros/src/attrs/mod.rs index 1231db5d3..6eb6b96bd 100644 --- a/crates/stackable-versioned-macros/src/attrs/mod.rs +++ b/crates/stackable-versioned-macros/src/attrs/mod.rs @@ -1,2 +1,3 @@ -pub(crate) mod container; +pub(crate) mod common; pub(crate) mod field; +pub(crate) mod variant; diff --git a/crates/stackable-versioned-macros/src/attrs/variant.rs b/crates/stackable-versioned-macros/src/attrs/variant.rs new file mode 100644 index 000000000..75f3ebd59 --- /dev/null +++ b/crates/stackable-versioned-macros/src/attrs/variant.rs @@ -0,0 +1,233 @@ +use convert_case::{Case, Casing}; +use darling::{Error, FromVariant}; +use syn::{Ident, Variant}; + +use crate::{ + attrs::common::{ContainerAttributes, ItemAttributes}, + consts::DEPRECATED_VARIANT_PREFIX, +}; + +#[derive(Debug, FromVariant)] +#[darling( + attributes(versioned), + forward_attrs(allow, doc, cfg, serde), + and_then = VariantAttributes::validate +)] +pub(crate) struct VariantAttributes { + #[darling(flatten)] + pub(crate) common: ItemAttributes, + + // The ident (automatically extracted by darling) cannot be moved into the + // shared item attributes because for struct fields, the type is + // `Option`, while for enum variants, the type is `Ident`. + pub(crate) ident: Ident, +} + +impl VariantAttributes { + // NOTE (@Techassi): Ideally, these validations should be moved to the + // ItemAttributes impl, because common validation like action combinations + // and action order can be validated without taking the type of attribute + // into account (field vs variant). However, we would loose access to the + // field / variant ident and as such, cannot display the error directly on + // the affected field / variant. This is a significant decrease in DX. + // See https://github.com/TedDriggs/darling/discussions/294 + + /// This associated function is called by darling (see and_then attribute) + /// after it successfully parsed the attribute. This allows custom + /// validation of the attribute which extends the validation already in + /// place by darling. + /// + /// Internally, it calls out to other specialized validation functions. + fn validate(self) -> Result { + let mut errors = Error::accumulator(); + + // Semantic validation + errors.handle(self.validate_action_combinations()); + errors.handle(self.validate_action_order()); + errors.handle(self.validate_variant_name()); + + // TODO (@Techassi): Add hint if a item is added in the first version + // that it might be clever to remove the 'added' attribute. + + errors.finish()?; + Ok(self) + } + + /// This associated function is called by the top-level validation function + /// and validates that each variant uses a valid combination of actions. + /// Invalid combinations are: + /// + /// - `added` and `deprecated` using the same version: A variant cannot be + /// marked as added in a particular version and then marked as deprecated + /// immediately after. Variants must be included for at least one version + /// before being marked deprecated. + /// - `added` and `renamed` using the same version: The same reasoning from + /// above applies here as well. Variants must be included for at least one + /// version before being renamed. + /// - `renamed` and `deprecated` using the same version: Again, the same + /// rules from above apply here as well. + fn validate_action_combinations(&self) -> Result<(), Error> { + match ( + &self.common.added, + &self.common.renames, + &self.common.deprecated, + ) { + (Some(added), _, Some(deprecated)) if *added.since == *deprecated.since => { + Err(Error::custom( + "variant cannot be marked as `added` and `deprecated` in the same version", + ) + .with_span(&self.ident)) + } + (Some(added), renamed, _) if renamed.iter().any(|r| *r.since == *added.since) => { + Err(Error::custom( + "variant cannot be marked as `added` and `renamed` in the same version", + ) + .with_span(&self.ident)) + } + (_, renamed, Some(deprecated)) + if renamed.iter().any(|r| *r.since == *deprecated.since) => + { + Err(Error::custom( + "variant cannot be marked as `deprecated` and `renamed` in the same version", + ) + .with_span(&self.ident)) + } + _ => Ok(()), + } + } + + /// This associated function is called by the top-level validation function + /// and validates that actions use a chronologically sound chain of + /// versions. + /// + /// The following rules apply: + /// + /// - `deprecated` must use a greater version than `added`: This function + /// ensures that these versions are chronologically sound, that means, + /// that the version of the deprecated action must be greater than the + /// version of the added action. + /// - All `renamed` actions must use a greater version than `added` but a + /// lesser version than `deprecated`. + fn validate_action_order(&self) -> Result<(), Error> { + let added_version = self.common.added.as_ref().map(|a| *a.since); + let deprecated_version = self.common.deprecated.as_ref().map(|d| *d.since); + + // First, validate that the added version is less than the deprecated + // version. + // NOTE (@Techassi): Is this already covered by the code below? + if let (Some(added_version), Some(deprecated_version)) = (added_version, deprecated_version) + { + if added_version > deprecated_version { + return Err(Error::custom(format!( + "variant was marked as `added` in version `{added_version}` while being marked as `deprecated` in an earlier version `{deprecated_version}`" + )).with_span(&self.ident)); + } + } + + // Now, iterate over all renames and ensure that their versions are + // between the added and deprecated version. + if !self.common.renames.iter().all(|r| { + added_version.map_or(true, |a| a < *r.since) + && deprecated_version.map_or(true, |d| d > *r.since) + }) { + return Err(Error::custom( + "all renames must use versions higher than `added` and lower than `deprecated`", + ) + .with_span(&self.ident)); + } + + Ok(()) + } + + /// This associated function is called by the top-level validation function + /// and validates that variants use correct names depending on attached + /// actions. + /// + /// The following naming rules apply: + /// + /// - Variants marked as deprecated need to include the 'deprecated_' prefix + /// in their name. The prefix must not be included for variants which are + /// not deprecated. + fn validate_variant_name(&self) -> Result<(), Error> { + if !self + .common + .renames + .iter() + .all(|r| r.from.is_case(Case::Pascal)) + { + return Err(Error::custom("renamed variants must use PascalCase")); + } + + let starts_with_deprecated = self + .ident + .to_string() + .starts_with(DEPRECATED_VARIANT_PREFIX); + + if self.common.deprecated.is_some() && !starts_with_deprecated { + return Err(Error::custom( + "variant was marked as `deprecated` and thus must include the `Deprecated` prefix in its name" + ).with_span(&self.ident)); + } + + if self.common.deprecated.is_none() && starts_with_deprecated { + return Err(Error::custom( + "variant includes the `Deprecated` prefix in its name but is not marked as `deprecated`" + ).with_span(&self.ident)); + } + + Ok(()) + } + + pub(crate) fn validate_versions( + &self, + container_attrs: &ContainerAttributes, + variant: &Variant, + ) -> Result<(), Error> { + // NOTE (@Techassi): Can we maybe optimize this a little? + // TODO (@Techassi): Unify this with the field impl, e.g. by introducing + // a T: Spanned bound for the second function parameter. + let mut errors = Error::accumulator(); + + if let Some(added) = &self.common.added { + if !container_attrs + .versions + .iter() + .any(|v| v.name == *added.since) + { + errors.push(Error::custom( + "variant action `added` uses version which was not declared via #[versioned(version)]") + .with_span(&variant.ident) + ); + } + } + + for rename in &*self.common.renames { + if !container_attrs + .versions + .iter() + .any(|v| v.name == *rename.since) + { + errors.push( + Error::custom("variant action `renamed` uses version which was not declared via #[versioned(version)]") + .with_span(&variant.ident) + ); + } + } + + if let Some(deprecated) = &self.common.deprecated { + if !container_attrs + .versions + .iter() + .any(|v| v.name == *deprecated.since) + { + errors.push(Error::custom( + "variant action `deprecated` uses version which was not declared via #[versioned(version)]") + .with_span(&variant.ident) + ); + } + } + + errors.finish()?; + Ok(()) + } +} diff --git a/crates/stackable-versioned-macros/src/consts.rs b/crates/stackable-versioned-macros/src/consts.rs index bb0ff076b..d064c344d 100644 --- a/crates/stackable-versioned-macros/src/consts.rs +++ b/crates/stackable-versioned-macros/src/consts.rs @@ -1 +1,2 @@ -pub(crate) const DEPRECATED_PREFIX: &str = "deprecated_"; +pub(crate) const DEPRECATED_VARIANT_PREFIX: &str = "Deprecated"; +pub(crate) const DEPRECATED_FIELD_PREFIX: &str = "deprecated_"; diff --git a/crates/stackable-versioned-macros/src/gen/neighbors.rs b/crates/stackable-versioned-macros/src/gen/chain.rs similarity index 85% rename from crates/stackable-versioned-macros/src/gen/neighbors.rs rename to crates/stackable-versioned-macros/src/gen/chain.rs index c37955a39..47675bcb6 100644 --- a/crates/stackable-versioned-macros/src/gen/neighbors.rs +++ b/crates/stackable-versioned-macros/src/gen/chain.rs @@ -51,6 +51,26 @@ where } } +pub(crate) trait BTreeMapExt +where + K: Ord, +{ + const MESSAGE: &'static str; + + fn get_expect(&self, key: &K) -> &V; +} + +impl BTreeMapExt for BTreeMap +where + K: Ord, +{ + const MESSAGE: &'static str = "internal error: chain must contain version"; + + fn get_expect(&self, key: &K) -> &V { + self.get(key).expect(Self::MESSAGE) + } +} + #[cfg(test)] mod test { use super::*; diff --git a/crates/stackable-versioned-macros/src/gen/common.rs b/crates/stackable-versioned-macros/src/gen/common.rs new file mode 100644 index 000000000..298b85c11 --- /dev/null +++ b/crates/stackable-versioned-macros/src/gen/common.rs @@ -0,0 +1,110 @@ +use std::{collections::BTreeMap, ops::Deref}; + +use k8s_version::Version; +use proc_macro2::{Span, TokenStream}; +use quote::format_ident; +use syn::{Ident, Path}; + +use crate::{ + attrs::common::ContainerAttributes, + consts::{DEPRECATED_FIELD_PREFIX, DEPRECATED_VARIANT_PREFIX}, +}; + +pub(crate) type VersionChain = BTreeMap; + +#[derive(Debug, Clone)] +pub(crate) struct ContainerVersion { + pub(crate) deprecated: bool, + pub(crate) skip_from: bool, + pub(crate) inner: Version, + pub(crate) ident: Ident, +} + +impl From<&ContainerAttributes> for Vec { + fn from(attributes: &ContainerAttributes) -> Self { + attributes + .versions + .iter() + .map(|v| ContainerVersion { + skip_from: v.skip.as_ref().map_or(false, |s| s.from.is_present()), + ident: Ident::new(&v.name.to_string(), Span::call_site()), + deprecated: v.deprecated.is_present(), + inner: v.name, + }) + .collect() + } +} + +pub(crate) trait Container +where + Self: Sized + Deref>, +{ + fn new(ident: Ident, data: D, attributes: ContainerAttributes) -> syn::Result; + + /// This generates the complete code for a single versioned container. + /// + /// Internally, it will create a module for each declared version which + /// contains the container with the appropriate items (fields or variants) + /// Additionally, it generates `From` implementations, which enable + /// conversion from an older to a newer version. + fn generate_tokens(&self) -> TokenStream; +} + +#[derive(Debug)] +pub(crate) struct VersionedContainer { + pub(crate) versions: Vec, + pub(crate) items: Vec, + pub(crate) ident: Ident, + + pub(crate) from_ident: Ident, + pub(crate) skip_from: bool, +} + +#[derive(Debug)] +pub(crate) enum ItemStatus { + Added { + ident: Ident, + default_fn: Path, + }, + Renamed { + from: Ident, + to: Ident, + }, + Deprecated { + previous_ident: Ident, + ident: Ident, + note: String, + }, + NoChange(Ident), + NotPresent, +} + +impl ItemStatus { + pub(crate) fn get_ident(&self) -> Option<&Ident> { + match &self { + ItemStatus::Added { ident, .. } => Some(ident), + ItemStatus::Renamed { to, .. } => Some(to), + ItemStatus::Deprecated { ident, .. } => Some(ident), + ItemStatus::NoChange(ident) => Some(ident), + ItemStatus::NotPresent => None, + } + } +} + +/// Returns the container ident used in [`From`] implementations. +pub(crate) fn format_container_from_ident(ident: &Ident) -> Ident { + format_ident!("__sv_{ident}", ident = ident.to_string().to_lowercase()) +} + +/// Removes the deprecated prefix from field ident. +pub(crate) fn remove_deprecated_field_prefix(ident: &Ident) -> Ident { + remove_ident_prefix(ident, DEPRECATED_FIELD_PREFIX) +} + +pub(crate) fn remove_deprecated_variant_prefix(ident: &Ident) -> Ident { + remove_ident_prefix(ident, DEPRECATED_VARIANT_PREFIX) +} + +pub(crate) fn remove_ident_prefix(ident: &Ident, prefix: &str) -> Ident { + format_ident!("{}", ident.to_string().trim_start_matches(prefix)) +} diff --git a/crates/stackable-versioned-macros/src/gen/mod.rs b/crates/stackable-versioned-macros/src/gen/mod.rs index bf64fa495..ea069d445 100644 --- a/crates/stackable-versioned-macros/src/gen/mod.rs +++ b/crates/stackable-versioned-macros/src/gen/mod.rs @@ -1,11 +1,14 @@ use proc_macro2::TokenStream; use syn::{spanned::Spanned, Data, DeriveInput, Error, Result}; -use crate::{attrs::container::ContainerAttributes, gen::vstruct::VersionedStruct}; +use crate::{ + attrs::common::ContainerAttributes, + gen::{common::Container, venum::VersionedEnum, vstruct::VersionedStruct}, +}; -pub(crate) mod field; -pub(crate) mod neighbors; -pub(crate) mod version; +pub(crate) mod chain; +pub(crate) mod common; +pub(crate) mod venum; pub(crate) mod vstruct; // NOTE (@Techassi): This derive macro cannot handle multiple structs / enums @@ -20,13 +23,16 @@ pub(crate) mod vstruct; // TODO (@Techassi): Think about how we can handle nested structs / enums which // are also versioned. -pub(crate) fn expand(attrs: ContainerAttributes, input: DeriveInput) -> Result { +pub(crate) fn expand(attributes: ContainerAttributes, input: DeriveInput) -> Result { let expanded = match input.data { - Data::Struct(data) => VersionedStruct::new(input.ident, data, attrs)?.generate_tokens(), + Data::Struct(data) => { + VersionedStruct::new(input.ident, data, attributes)?.generate_tokens() + } + Data::Enum(data) => VersionedEnum::new(input.ident, data, attributes)?.generate_tokens(), _ => { return Err(Error::new( input.span(), - "attribute macro `versioned` only supports structs", + "attribute macro `versioned` only supports structs and enums", )) } }; diff --git a/crates/stackable-versioned-macros/src/gen/venum/mod.rs b/crates/stackable-versioned-macros/src/gen/venum/mod.rs new file mode 100644 index 000000000..3d417ecef --- /dev/null +++ b/crates/stackable-versioned-macros/src/gen/venum/mod.rs @@ -0,0 +1,182 @@ +use std::ops::Deref; + +use itertools::Itertools; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{DataEnum, Error, Ident}; + +use crate::{ + attrs::common::ContainerAttributes, + gen::{ + common::{format_container_from_ident, Container, ContainerVersion, VersionedContainer}, + venum::variant::VersionedVariant, + }, +}; + +mod variant; + +#[derive(Debug)] +pub(crate) struct VersionedEnum(VersionedContainer); + +impl Deref for VersionedEnum { + type Target = VersionedContainer; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Container for VersionedEnum { + fn new(ident: Ident, data: DataEnum, attributes: ContainerAttributes) -> syn::Result { + // Convert the raw version attributes into a container version. + let versions: Vec<_> = (&attributes).into(); + + // Extract the attributes for every variant from the raw token + // stream and also validate that each variant action version uses a + // version declared by the container attribute. + let mut items = Vec::new(); + + for variant in data.variants { + let mut versioned_field = VersionedVariant::new(variant, &attributes)?; + versioned_field.insert_container_versions(&versions); + items.push(versioned_field); + } + + // Check for field ident collisions + for version in &versions { + // Collect the idents of all variants for a single version and then + // ensure that all idents are unique. If they are not, return an + // error. + + // TODO (@Techassi): Report which variant(s) use a duplicate ident and + // also hint what can be done to fix it based on the variant action / + // status. + + if !items.iter().map(|f| f.get_ident(version)).all_unique() { + return Err(Error::new( + ident.span(), + format!("struct contains renamed fields which collide with other fields in version {version}", version = version.inner), + )); + } + } + + let from_ident = format_container_from_ident(&ident); + + Ok(Self(VersionedContainer { + skip_from: attributes + .options + .skip + .map_or(false, |s| s.from.is_present()), + from_ident, + versions, + items, + ident, + })) + } + + fn generate_tokens(&self) -> TokenStream { + let mut token_stream = TokenStream::new(); + let mut versions = self.versions.iter().peekable(); + + while let Some(version) = versions.next() { + token_stream.extend(self.generate_version(version, versions.peek().copied())); + } + + token_stream + } +} + +impl VersionedEnum { + fn generate_version( + &self, + version: &ContainerVersion, + next_version: Option<&ContainerVersion>, + ) -> TokenStream { + let mut token_stream = TokenStream::new(); + let enum_name = &self.ident; + + // Generate variants of the enum for `version`. + let variants = self.generate_enum_variants(version); + + // TODO (@Techassi): Make the generation of the module optional to + // enable the attribute macro to be applied to a module which + // generates versioned versions of all contained containers. + + let version_ident = &version.ident; + + let deprecated_note = format!("Version {version} is deprecated", version = version_ident); + let deprecated_attr = version + .deprecated + .then_some(quote! {#[deprecated = #deprecated_note]}); + + // Generate tokens for the module and the contained struct + token_stream.extend(quote! { + #[automatically_derived] + #deprecated_attr + pub mod #version_ident { + pub enum #enum_name { + #variants + } + } + }); + + // Generate the From impl between this `version` and the next one. + if !self.skip_from && !version.skip_from { + token_stream.extend(self.generate_from_impl(version, next_version)); + } + + token_stream + } + + fn generate_enum_variants(&self, version: &ContainerVersion) -> TokenStream { + let mut token_stream = TokenStream::new(); + + for variant in &self.items { + token_stream.extend(variant.generate_for_container(version)); + } + + token_stream + } + + fn generate_from_impl( + &self, + version: &ContainerVersion, + next_version: Option<&ContainerVersion>, + ) -> TokenStream { + if let Some(next_version) = next_version { + let next_module_name = &next_version.ident; + let module_name = &version.ident; + + let from_ident = &self.from_ident; + let enum_ident = &self.ident; + + let mut variants = TokenStream::new(); + + for item in &self.items { + variants.extend(item.generate_for_from_impl( + module_name, + next_module_name, + version, + next_version, + enum_ident, + )) + } + + // TODO (@Techassi): Be a little bit more clever about when to include + // the #[allow(deprecated)] attribute. + return quote! { + #[automatically_derived] + #[allow(deprecated)] + impl From<#module_name::#enum_ident> for #next_module_name::#enum_ident { + fn from(#from_ident: #module_name::#enum_ident) -> Self { + match #from_ident { + #variants + } + } + } + }; + } + + quote! {} + } +} diff --git a/crates/stackable-versioned-macros/src/gen/venum/variant.rs b/crates/stackable-versioned-macros/src/gen/venum/variant.rs new file mode 100644 index 000000000..d06c29651 --- /dev/null +++ b/crates/stackable-versioned-macros/src/gen/venum/variant.rs @@ -0,0 +1,281 @@ +use std::{collections::BTreeMap, ops::Deref}; + +use darling::FromVariant; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Ident, Variant}; + +use crate::{ + attrs::{common::ContainerAttributes, variant::VariantAttributes}, + gen::{ + chain::{BTreeMapExt, Neighbors}, + common::{remove_deprecated_variant_prefix, ContainerVersion, ItemStatus, VersionChain}, + }, +}; + +#[derive(Debug)] +pub(crate) struct VersionedVariant { + chain: Option, + inner: Variant, +} + +// TODO (@Techassi): Figure out a way to be able to only write the following code +// once for both a versioned field and variant, because the are practically +// identical. + +impl VersionedVariant { + pub(crate) fn new( + variant: Variant, + container_attrs: &ContainerAttributes, + ) -> syn::Result { + // NOTE (@Techassi): This is straight up copied from the VersionedField + // impl. As mentioned above, unify this. + + let variant_attrs = VariantAttributes::from_variant(&variant)?; + variant_attrs.validate_versions(container_attrs, &variant)?; + + if let Some(deprecated) = variant_attrs.common.deprecated { + let deprecated_ident = &variant.ident; + + // When the variant is deprecated, any rename which occurred beforehand + // requires access to the variant ident to infer the variant ident for + // the latest rename. + let mut ident = remove_deprecated_variant_prefix(deprecated_ident); + let mut actions = BTreeMap::new(); + + actions.insert( + *deprecated.since, + ItemStatus::Deprecated { + previous_ident: ident.clone(), + ident: deprecated_ident.clone(), + note: deprecated.note.to_string(), + }, + ); + + for rename in variant_attrs.common.renames.iter().rev() { + let from = format_ident!("{from}", from = *rename.from); + actions.insert( + *rename.since, + ItemStatus::Renamed { + from: from.clone(), + to: ident, + }, + ); + ident = from; + } + + // After the last iteration above (if any) we use the ident for the + // added action if there is any. + if let Some(added) = variant_attrs.common.added { + actions.insert( + *added.since, + ItemStatus::Added { + default_fn: added.default_fn.deref().clone(), + ident, + }, + ); + } + + Ok(Self { + chain: Some(actions), + inner: variant, + }) + } else if !variant_attrs.common.renames.is_empty() { + let mut actions = BTreeMap::new(); + let mut ident = variant.ident.clone(); + + for rename in variant_attrs.common.renames.iter().rev() { + let from = format_ident!("{from}", from = *rename.from); + actions.insert( + *rename.since, + ItemStatus::Renamed { + from: from.clone(), + to: ident, + }, + ); + ident = from; + } + + // After the last iteration above (if any) we use the ident for the + // added action if there is any. + if let Some(added) = variant_attrs.common.added { + actions.insert( + *added.since, + ItemStatus::Added { + default_fn: added.default_fn.deref().clone(), + ident, + }, + ); + } + + Ok(Self { + chain: Some(actions), + inner: variant, + }) + } else { + if let Some(added) = variant_attrs.common.added { + let mut actions = BTreeMap::new(); + + actions.insert( + *added.since, + ItemStatus::Added { + default_fn: added.default_fn.deref().clone(), + ident: variant.ident.clone(), + }, + ); + + return Ok(Self { + chain: Some(actions), + inner: variant, + }); + } + + Ok(Self { + chain: None, + inner: variant, + }) + } + } + + /// Inserts container versions not yet present in the status chain. + /// + /// When initially creating a new versioned item, the code doesn't have + /// access to the versions defined on the container. This function inserts + /// all non-present container versions and decides which status and ident + /// is the right fit based on the status neighbors. + /// + /// This continuous chain ensures that when generating code (tokens), each + /// variant can lookup the status (and ident) for a requested version. + pub(crate) fn insert_container_versions(&mut self, versions: &[ContainerVersion]) { + if let Some(chain) = &mut self.chain { + for version in versions { + if chain.contains_key(&version.inner) { + continue; + } + + match chain.get_neighbors(&version.inner) { + (None, Some(status)) => match status { + ItemStatus::Added { .. } => { + chain.insert(version.inner, ItemStatus::NotPresent) + } + ItemStatus::Renamed { from, .. } => { + chain.insert(version.inner, ItemStatus::NoChange(from.clone())) + } + ItemStatus::Deprecated { previous_ident, .. } => chain + .insert(version.inner, ItemStatus::NoChange(previous_ident.clone())), + ItemStatus::NoChange(ident) => { + chain.insert(version.inner, ItemStatus::NoChange(ident.clone())) + } + ItemStatus::NotPresent => unreachable!(), + }, + (Some(status), None) => { + let ident = match status { + ItemStatus::Added { ident, .. } => ident, + ItemStatus::Renamed { to, .. } => to, + ItemStatus::Deprecated { ident, .. } => ident, + ItemStatus::NoChange(ident) => ident, + ItemStatus::NotPresent => unreachable!(), + }; + + chain.insert(version.inner, ItemStatus::NoChange(ident.clone())) + } + (Some(status), Some(_)) => { + let ident = match status { + ItemStatus::Added { ident, .. } => ident, + ItemStatus::Renamed { to, .. } => to, + ItemStatus::NoChange(ident) => ident, + _ => unreachable!(), + }; + + chain.insert(version.inner, ItemStatus::NoChange(ident.clone())) + } + _ => unreachable!(), + }; + } + } + } + + pub(crate) fn generate_for_container( + &self, + container_version: &ContainerVersion, + ) -> Option { + match &self.chain { + Some(chain) => match chain + .get(&container_version.inner) + .expect("internal error: chain must contain container version") + { + ItemStatus::Added { ident, .. } => Some(quote! { + #ident, + }), + ItemStatus::Renamed { to, .. } => Some(quote! { + #to, + }), + ItemStatus::Deprecated { ident, .. } => Some(quote! { + #[deprecated] + #ident, + }), + ItemStatus::NoChange(ident) => Some(quote! { + #ident, + }), + ItemStatus::NotPresent => None, + }, + None => { + // If there is no chain of variant actions, the variant is not + // versioned and code generation is straight forward. + // Unversioned variants are always included in versioned enums. + let variant_ident = &self.inner.ident; + + Some(quote! { + #variant_ident, + }) + } + } + } + + pub(crate) fn generate_for_from_impl( + &self, + module_name: &Ident, + next_module_name: &Ident, + version: &ContainerVersion, + next_version: &ContainerVersion, + enum_ident: &Ident, + ) -> TokenStream { + match &self.chain { + Some(chain) => match ( + chain.get_expect(&version.inner), + chain.get_expect(&next_version.inner), + ) { + (_, ItemStatus::Added { .. }) => quote! {}, + (old, next) => { + let old_variant_ident = old + .get_ident() + .expect("internal error: old variant must have a name"); + let next_variant_ident = next + .get_ident() + .expect("internal error: next variant must have a name"); + + quote! { + #module_name::#enum_ident::#old_variant_ident => #next_module_name::#enum_ident::#next_variant_ident, + } + } + }, + None => { + let variant_ident = &self.inner.ident; + + quote! { + #module_name::#enum_ident::#variant_ident => #next_module_name::#enum_ident::#variant_ident, + } + } + } + } + + pub(crate) fn get_ident(&self, version: &ContainerVersion) -> Option<&syn::Ident> { + match &self.chain { + Some(chain) => chain + .get(&version.inner) + .expect("internal error: chain must contain container version") + .get_ident(), + None => Some(&self.inner.ident), + } + } +} diff --git a/crates/stackable-versioned-macros/src/gen/version.rs b/crates/stackable-versioned-macros/src/gen/version.rs deleted file mode 100644 index e4375090e..000000000 --- a/crates/stackable-versioned-macros/src/gen/version.rs +++ /dev/null @@ -1,10 +0,0 @@ -use k8s_version::Version; -use syn::Ident; - -#[derive(Debug, Clone)] -pub(crate) struct ContainerVersion { - pub(crate) deprecated: bool, - pub(crate) skip_from: bool, - pub(crate) inner: Version, - pub(crate) ident: Ident, -} diff --git a/crates/stackable-versioned-macros/src/gen/field.rs b/crates/stackable-versioned-macros/src/gen/vstruct/field.rs similarity index 65% rename from crates/stackable-versioned-macros/src/gen/field.rs rename to crates/stackable-versioned-macros/src/gen/vstruct/field.rs index c9ee4463a..c3b77a7e7 100644 --- a/crates/stackable-versioned-macros/src/gen/field.rs +++ b/crates/stackable-versioned-macros/src/gen/vstruct/field.rs @@ -1,15 +1,16 @@ use std::{collections::BTreeMap, ops::Deref}; -use darling::Error; -use k8s_version::Version; +use darling::FromField; use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Field, Ident, Path}; +use syn::{Field, Ident}; use crate::{ - attrs::field::FieldAttributes, - consts::DEPRECATED_PREFIX, - gen::{neighbors::Neighbors, version::ContainerVersion}, + attrs::{common::ContainerAttributes, field::FieldAttributes}, + gen::{ + chain::Neighbors, + common::{remove_deprecated_field_prefix, ContainerVersion, ItemStatus, VersionChain}, + }, }; /// A versioned field, which contains contains common [`Field`] data and a chain @@ -20,17 +21,22 @@ use crate::{ /// generate documentation, etc. #[derive(Debug)] pub(crate) struct VersionedField { - chain: Option>, - inner: Field, + pub(crate) chain: Option, + pub(crate) inner: Field, } +// TODO (@Techassi): Figure out a way to be able to only write the following code +// once for both a versioned field and variant, because the are practically +// identical. + impl VersionedField { - /// Create a new versioned field by creating a status chain for each version - /// defined in an action in the field attribute. - /// - /// This chain will get extended by the versions defined on the container by - /// calling the [`VersionedField::insert_container_versions`] function. - pub(crate) fn new(field: Field, attrs: FieldAttributes) -> Result { + /// Create a new versioned field (of a versioned struct) by consuming the + /// parsed [Field] and validating the versions of field actions against + /// versions attached on the container. + pub(crate) fn new(field: Field, container_attrs: &ContainerAttributes) -> syn::Result { + let field_attrs = FieldAttributes::from_field(&field)?; + field_attrs.validate_versions(container_attrs, &field)?; + // Constructing the action chain requires going through the actions from // the end, because the base struct always represents the latest (most // up-to-date) version of that struct. That's why the following code @@ -42,33 +48,32 @@ impl VersionedField { // rename or addition, which is handled below. // The ident of the deprecated field is guaranteed to include the // 'deprecated_' prefix. The ident can thus be used as is. - if let Some(deprecated) = attrs.deprecated { - let deprecated_ident = field.ident.as_ref().unwrap(); + if let Some(deprecated) = field_attrs.common.deprecated { + let deprecated_ident = field + .ident + .as_ref() + .expect("internal error: field must have an ident"); // When the field is deprecated, any rename which occurred beforehand // requires access to the field ident to infer the field ident for // the latest rename. - let mut ident = format_ident!( - "{ident}", - ident = deprecated_ident.to_string().replace(DEPRECATED_PREFIX, "") - ); - + let mut ident = remove_deprecated_field_prefix(deprecated_ident); let mut actions = BTreeMap::new(); actions.insert( *deprecated.since, - FieldStatus::Deprecated { + ItemStatus::Deprecated { previous_ident: ident.clone(), ident: deprecated_ident.clone(), note: deprecated.note.to_string(), }, ); - for rename in attrs.renames.iter().rev() { + for rename in field_attrs.common.renames.iter().rev() { let from = format_ident!("{from}", from = *rename.from); actions.insert( *rename.since, - FieldStatus::Renamed { + ItemStatus::Renamed { from: from.clone(), to: ident, }, @@ -78,10 +83,10 @@ impl VersionedField { // After the last iteration above (if any) we use the ident for the // added action if there is any. - if let Some(added) = attrs.added { + if let Some(added) = field_attrs.common.added { actions.insert( *added.since, - FieldStatus::Added { + ItemStatus::Added { default_fn: added.default_fn.deref().clone(), ident, }, @@ -92,15 +97,18 @@ impl VersionedField { chain: Some(actions), inner: field, }) - } else if !attrs.renames.is_empty() { + } else if !field_attrs.common.renames.is_empty() { let mut actions = BTreeMap::new(); - let mut ident = field.ident.clone().unwrap(); + let mut ident = field + .ident + .clone() + .expect("internal error: field must have an ident"); - for rename in attrs.renames.iter().rev() { + for rename in field_attrs.common.renames.iter().rev() { let from = format_ident!("{from}", from = *rename.from); actions.insert( *rename.since, - FieldStatus::Renamed { + ItemStatus::Renamed { from: from.clone(), to: ident, }, @@ -110,10 +118,10 @@ impl VersionedField { // After the last iteration above (if any) we use the ident for the // added action if there is any. - if let Some(added) = attrs.added { + if let Some(added) = field_attrs.common.added { actions.insert( *added.since, - FieldStatus::Added { + ItemStatus::Added { default_fn: added.default_fn.deref().clone(), ident, }, @@ -125,14 +133,17 @@ impl VersionedField { inner: field, }) } else { - if let Some(added) = attrs.added { + if let Some(added) = field_attrs.common.added { let mut actions = BTreeMap::new(); actions.insert( *added.since, - FieldStatus::Added { + ItemStatus::Added { default_fn: added.default_fn.deref().clone(), - ident: field.ident.clone().unwrap(), + ident: field + .ident + .clone() + .expect("internal error: field must have a name"), }, ); @@ -151,14 +162,14 @@ impl VersionedField { /// Inserts container versions not yet present in the status chain. /// - /// When initially creating a new [`VersionedField`], the code doesn't have + /// When initially creating a new versioned item, the code doesn't have /// access to the versions defined on the container. This function inserts /// all non-present container versions and decides which status and ident /// is the right fit based on the status neighbors. /// /// This continuous chain ensures that when generating code (tokens), each - /// field can lookup the status for a requested version. - pub(crate) fn insert_container_versions(&mut self, versions: &Vec) { + /// field can lookup the status (and ident) for a requested version. + pub(crate) fn insert_container_versions(&mut self, versions: &[ContainerVersion]) { if let Some(chain) = &mut self.chain { for version in versions { if chain.contains_key(&version.inner) { @@ -167,39 +178,39 @@ impl VersionedField { match chain.get_neighbors(&version.inner) { (None, Some(status)) => match status { - FieldStatus::Added { .. } => { - chain.insert(version.inner, FieldStatus::NotPresent) + ItemStatus::Added { .. } => { + chain.insert(version.inner, ItemStatus::NotPresent) } - FieldStatus::Renamed { from, .. } => { - chain.insert(version.inner, FieldStatus::NoChange(from.clone())) + ItemStatus::Renamed { from, .. } => { + chain.insert(version.inner, ItemStatus::NoChange(from.clone())) } - FieldStatus::Deprecated { previous_ident, .. } => chain - .insert(version.inner, FieldStatus::NoChange(previous_ident.clone())), - FieldStatus::NoChange(ident) => { - chain.insert(version.inner, FieldStatus::NoChange(ident.clone())) + ItemStatus::Deprecated { previous_ident, .. } => chain + .insert(version.inner, ItemStatus::NoChange(previous_ident.clone())), + ItemStatus::NoChange(ident) => { + chain.insert(version.inner, ItemStatus::NoChange(ident.clone())) } - FieldStatus::NotPresent => unreachable!(), + ItemStatus::NotPresent => unreachable!(), }, (Some(status), None) => { let ident = match status { - FieldStatus::Added { ident, .. } => ident, - FieldStatus::Renamed { to, .. } => to, - FieldStatus::Deprecated { ident, .. } => ident, - FieldStatus::NoChange(ident) => ident, - FieldStatus::NotPresent => unreachable!(), + ItemStatus::Added { ident, .. } => ident, + ItemStatus::Renamed { to, .. } => to, + ItemStatus::Deprecated { ident, .. } => ident, + ItemStatus::NoChange(ident) => ident, + ItemStatus::NotPresent => unreachable!(), }; - chain.insert(version.inner, FieldStatus::NoChange(ident.clone())) + chain.insert(version.inner, ItemStatus::NoChange(ident.clone())) } (Some(status), Some(_)) => { let ident = match status { - FieldStatus::Added { ident, .. } => ident, - FieldStatus::Renamed { to, .. } => to, - FieldStatus::NoChange(ident) => ident, + ItemStatus::Added { ident, .. } => ident, + ItemStatus::Renamed { to, .. } => to, + ItemStatus::NoChange(ident) => ident, _ => unreachable!(), }; - chain.insert(version.inner, FieldStatus::NoChange(ident.clone())) + chain.insert(version.inner, ItemStatus::NoChange(ident.clone())) } _ => unreachable!(), }; @@ -207,7 +218,17 @@ impl VersionedField { } } - pub(crate) fn generate_for_struct( + pub(crate) fn get_ident(&self, version: &ContainerVersion) -> Option<&Ident> { + match &self.chain { + Some(chain) => chain + .get(&version.inner) + .expect("internal error: chain must contain container version") + .get_ident(), + None => self.inner.ident.as_ref(), + } + } + + pub(crate) fn generate_for_container( &self, container_version: &ContainerVersion, ) -> Option { @@ -227,13 +248,13 @@ impl VersionedField { .get(&container_version.inner) .expect("internal error: chain must contain container version") { - FieldStatus::Added { ident, .. } => Some(quote! { + ItemStatus::Added { ident, .. } => Some(quote! { pub #ident: #field_type, }), - FieldStatus::Renamed { from: _, to } => Some(quote! { + ItemStatus::Renamed { to, .. } => Some(quote! { pub #to: #field_type, }), - FieldStatus::Deprecated { + ItemStatus::Deprecated { ident: field_ident, note, .. @@ -241,8 +262,8 @@ impl VersionedField { #[deprecated = #note] pub #field_ident: #field_type, }), - FieldStatus::NotPresent => None, - FieldStatus::NoChange(field_ident) => Some(quote! { + ItemStatus::NotPresent => None, + ItemStatus::NoChange(field_ident) => Some(quote! { pub #field_ident: #field_type, }), } @@ -277,12 +298,17 @@ impl VersionedField { .get(&next_version.inner) .expect("internal error: chain must contain container version"), ) { - (_, FieldStatus::Added { ident, default_fn }) => quote! { + (_, ItemStatus::Added { ident, default_fn }) => quote! { #ident: #default_fn(), }, (old, next) => { - let old_field_ident = old.get_ident().unwrap(); - let next_field_ident = next.get_ident().unwrap(); + let old_field_ident = old + .get_ident() + .expect("internal error: old field must have a name"); + + let next_field_ident = next + .get_ident() + .expect("internal error: new field must have a name"); quote! { #next_field_ident: #from_ident.#old_field_ident, @@ -298,45 +324,4 @@ impl VersionedField { } } } - - pub(crate) fn get_ident(&self, version: &ContainerVersion) -> Option<&Ident> { - match &self.chain { - Some(chain) => chain - .get(&version.inner) - .expect("internal error: chain must contain container version") - .get_ident(), - None => self.inner.ident.as_ref(), - } - } -} - -#[derive(Debug)] -pub(crate) enum FieldStatus { - Added { - ident: Ident, - default_fn: Path, - }, - Renamed { - from: Ident, - to: Ident, - }, - Deprecated { - previous_ident: Ident, - ident: Ident, - note: String, - }, - NoChange(Ident), - NotPresent, -} - -impl FieldStatus { - pub(crate) fn get_ident(&self) -> Option<&Ident> { - match &self { - FieldStatus::Added { ident, .. } => Some(ident), - FieldStatus::Renamed { to, .. } => Some(to), - FieldStatus::Deprecated { ident, .. } => Some(ident), - FieldStatus::NoChange(ident) => Some(ident), - FieldStatus::NotPresent => None, - } - } } diff --git a/crates/stackable-versioned-macros/src/gen/vstruct.rs b/crates/stackable-versioned-macros/src/gen/vstruct/mod.rs similarity index 60% rename from crates/stackable-versioned-macros/src/gen/vstruct.rs rename to crates/stackable-versioned-macros/src/gen/vstruct/mod.rs index fb2a62a71..6bbf6b98e 100644 --- a/crates/stackable-versioned-macros/src/gen/vstruct.rs +++ b/crates/stackable-versioned-macros/src/gen/vstruct/mod.rs @@ -1,67 +1,49 @@ -use darling::FromField; +use std::ops::Deref; + use itertools::Itertools; use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use syn::{DataStruct, Error, Ident, Result}; +use quote::quote; +use syn::{DataStruct, Error, Ident}; use crate::{ - attrs::{container::ContainerAttributes, field::FieldAttributes}, - gen::{field::VersionedField, version::ContainerVersion}, + attrs::common::ContainerAttributes, + gen::{ + common::{format_container_from_ident, Container, ContainerVersion, VersionedContainer}, + vstruct::field::VersionedField, + }, }; +mod field; + /// Stores individual versions of a single struct. Each version tracks field /// actions, which describe if the field was added, renamed or deprecated in /// that version. Fields which are not versioned, are included in every /// version of the struct. #[derive(Debug)] -pub(crate) struct VersionedStruct { - /// The ident, or name, of the versioned struct. - pub(crate) ident: Ident, - - /// The name of the struct used in `From` implementations. - pub(crate) from_ident: Ident, - - /// List of declared versions for this struct. Each version, except the - /// latest, generates a definition with appropriate fields. - pub(crate) versions: Vec, +pub(crate) struct VersionedStruct(VersionedContainer); - /// List of fields defined in the base struct. How, and if, a field should - /// generate code, is decided by the currently generated version. - pub(crate) fields: Vec, +impl Deref for VersionedStruct { + type Target = VersionedContainer; - pub(crate) skip_from: bool, + fn deref(&self) -> &Self::Target { + &self.0 + } } -impl VersionedStruct { - pub(crate) fn new( - ident: Ident, - data: DataStruct, - attributes: ContainerAttributes, - ) -> Result { +impl Container for VersionedStruct { + fn new(ident: Ident, data: DataStruct, attributes: ContainerAttributes) -> syn::Result { // Convert the raw version attributes into a container version. - let versions = attributes - .versions - .iter() - .map(|v| ContainerVersion { - skip_from: v.skip.as_ref().map_or(false, |s| s.from.is_present()), - ident: format_ident!("{version}", version = v.name.to_string()), - deprecated: v.deprecated.is_present(), - inner: v.name, - }) - .collect(); + let versions: Vec<_> = (&attributes).into(); // Extract the field attributes for every field from the raw token // stream and also validate that each field action version uses a // version declared by the container attribute. - let mut fields = Vec::new(); + let mut items = Vec::new(); for field in data.fields { - let attrs = FieldAttributes::from_field(&field)?; - attrs.validate_versions(&attributes, &field)?; - - let mut versioned_field = VersionedField::new(field, attrs)?; + let mut versioned_field = VersionedField::new(field, &attributes)?; versioned_field.insert_container_versions(&versions); - fields.push(versioned_field); + items.push(versioned_field); } // Check for field ident collisions @@ -69,17 +51,12 @@ impl VersionedStruct { // Collect the idents of all fields for a single version and then // ensure that all idents are unique. If they are not, return an // error. - let mut idents = Vec::new(); // TODO (@Techassi): Report which field(s) use a duplicate ident and // also hint what can be done to fix it based on the field action / // status. - for field in &fields { - idents.push(field.get_ident(version)) - } - - if !idents.iter().all_unique() { + if !items.iter().map(|f| f.get_ident(version)).all_unique() { return Err(Error::new( ident.span(), format!("struct contains renamed fields which collide with other fields in version {version}", version = version.inner), @@ -87,27 +64,21 @@ impl VersionedStruct { } } - let from_ident = format_ident!("__sv_{ident}", ident = ident.to_string().to_lowercase()); + let from_ident = format_container_from_ident(&ident); - Ok(Self { + Ok(Self(VersionedContainer { skip_from: attributes .options .skip .map_or(false, |s| s.from.is_present()), from_ident, versions, - fields, + items, ident, - }) + })) } - /// This generates the complete code for a single versioned struct. - /// - /// Internally, it will create a module for each declared version which - /// contains the struct with the appropriate fields. Additionally, it - /// generated `From` implementations, which enable conversion from an older - /// to a newer version. - pub(crate) fn generate_tokens(&self) -> TokenStream { + fn generate_tokens(&self) -> TokenStream { let mut token_stream = TokenStream::new(); let mut versions = self.versions.iter().peekable(); @@ -117,7 +88,9 @@ impl VersionedStruct { token_stream } +} +impl VersionedStruct { fn generate_version( &self, version: &ContainerVersion, @@ -133,14 +106,18 @@ impl VersionedStruct { // enable the attribute macro to be applied to a module which // generates versioned versions of all contained containers. - let deprecated_attr = version.deprecated.then_some(quote! {#[deprecated]}); - let module_name = &version.ident; + let version_ident = &version.ident; + + let deprecated_note = format!("Version {version} is deprecated", version = version_ident); + let deprecated_attr = version + .deprecated + .then_some(quote! {#[deprecated = #deprecated_note]}); // Generate tokens for the module and the contained struct token_stream.extend(quote! { #[automatically_derived] #deprecated_attr - pub mod #module_name { + pub mod #version_ident { pub struct #struct_name { #fields } @@ -158,8 +135,8 @@ impl VersionedStruct { fn generate_struct_fields(&self, version: &ContainerVersion) -> TokenStream { let mut token_stream = TokenStream::new(); - for field in &self.fields { - token_stream.extend(field.generate_for_struct(version)); + for item in &self.items { + token_stream.extend(item.generate_for_container(version)); } token_stream @@ -172,9 +149,10 @@ impl VersionedStruct { ) -> TokenStream { if let Some(next_version) = next_version { let next_module_name = &next_version.ident; - let from_ident = &self.from_ident; let module_name = &version.ident; - let struct_name = &self.ident; + + let from_ident = &self.from_ident; + let struct_ident = &self.ident; let fields = self.generate_from_fields(version, next_version, from_ident); @@ -183,8 +161,8 @@ impl VersionedStruct { return quote! { #[automatically_derived] #[allow(deprecated)] - impl From<#module_name::#struct_name> for #next_module_name::#struct_name { - fn from(#from_ident: #module_name::#struct_name) -> Self { + impl From<#module_name::#struct_ident> for #next_module_name::#struct_ident { + fn from(#from_ident: #module_name::#struct_ident) -> Self { Self { #fields } @@ -204,8 +182,8 @@ impl VersionedStruct { ) -> TokenStream { let mut token_stream = TokenStream::new(); - for field in &self.fields { - token_stream.extend(field.generate_for_from_impl(version, next_version, from_ident)) + for item in &self.items { + token_stream.extend(item.generate_for_from_impl(version, next_version, from_ident)) } token_stream diff --git a/crates/stackable-versioned-macros/src/lib.rs b/crates/stackable-versioned-macros/src/lib.rs index 9c8ce122d..44a8b7e51 100644 --- a/crates/stackable-versioned-macros/src/lib.rs +++ b/crates/stackable-versioned-macros/src/lib.rs @@ -2,7 +2,7 @@ use darling::{ast::NestedMeta, FromMeta}; use proc_macro::TokenStream; use syn::{DeriveInput, Error}; -use crate::attrs::container::ContainerAttributes; +use crate::attrs::common::ContainerAttributes; mod attrs; mod consts; diff --git a/crates/stackable-versioned-macros/tests/basic.rs b/crates/stackable-versioned-macros/tests/basic.rs index ef8a1c55b..ce46833c6 100644 --- a/crates/stackable-versioned-macros/tests/basic.rs +++ b/crates/stackable-versioned-macros/tests/basic.rs @@ -5,25 +5,26 @@ use stackable_versioned_macros::versioned; // run `cargo expand --test basic --all-features`. #[allow(dead_code)] #[versioned( - version(name = "v1alpha1"), + version(name = "v1alpha1", deprecated), version(name = "v1beta1"), version(name = "v1"), version(name = "v2"), version(name = "v3") )] struct Foo { - /// My docs #[versioned( added(since = "v1alpha1"), renamed(since = "v1beta1", from = "jjj"), deprecated(since = "v2", note = "not empty") )] + /// Test deprecated_bar: usize, baz: bool, } #[test] fn basic() { + #[allow(deprecated)] let _ = v1alpha1::Foo { jjj: 0, baz: false }; let _ = v1beta1::Foo { bar: 0, baz: false }; let _ = v1::Foo { bar: 0, baz: false }; diff --git a/crates/stackable-versioned-macros/tests/enum.rs b/crates/stackable-versioned-macros/tests/enum.rs new file mode 100644 index 000000000..c43b1da1e --- /dev/null +++ b/crates/stackable-versioned-macros/tests/enum.rs @@ -0,0 +1,23 @@ +use stackable_versioned_macros::versioned; + +#[test] +fn versioned_enum() { + #[versioned( + version(name = "v1alpha1"), + version(name = "v1beta1", deprecated), + version(name = "v1") + )] + pub enum Foo { + #[versioned(added(since = "v1beta1"), deprecated(since = "v1", note = "bye"))] + DeprecatedBar, + Baz, + } + + let v1alpha1_foo = v1alpha1::Foo::Baz; + #[allow(deprecated)] + let v1beta1_foo = v1beta1::Foo::from(v1alpha1_foo); + let v1_foo = v1::Foo::from(v1beta1_foo); + + // TODO (@Techassi): Forward derive PartialEq + assert!(matches!(v1_foo, v1::Foo::Baz)) +} diff --git a/crates/stackable-versioned/CHANGELOG.md b/crates/stackable-versioned/CHANGELOG.md index 20aed2ce0..345fc849e 100644 --- a/crates/stackable-versioned/CHANGELOG.md +++ b/crates/stackable-versioned/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- Add support for versioned enums ([#813]). - Add collision check for renamed fields ([#804]). - Add auto-generated `From for NEW` implementations ([#790]). @@ -19,6 +20,7 @@ All notable changes to this project will be documented in this file. [#790]: https://github.com/stackabletech/operator-rs/pull/790 [#793]: https://github.com/stackabletech/operator-rs/pull/793 [#804]: https://github.com/stackabletech/operator-rs/pull/804 +[#813]: https://github.com/stackabletech/operator-rs/pull/813 ## [0.1.0] - 2024-05-08