Skip to content

Commit cee9bd5

Browse files
refactor(stackable-versioned): Remove duplicate code (#820)
* Update changelog * Update PR link in changelog * refactor: Remove duplicated code for field and variant attrs * refactor: Unify field and variant attributes * docs: Update field and variant attribute docs * docs: Add doc and developer comments * chore: Apply suggestions Co-authored-by: Nick <[email protected]> --------- Co-authored-by: Nick <[email protected]>
1 parent 9a95d0b commit cee9bd5

File tree

19 files changed

+1202
-1229
lines changed

19 files changed

+1202
-1229
lines changed

Cargo.lock

Lines changed: 111 additions & 99 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/stackable-versioned-macros/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ convert_case.workspace = true
1616
darling.workspace = true
1717
itertools.workspace = true
1818
proc-macro2.workspace = true
19+
strum.workspace = true
1920
syn.workspace = true
2021
quote.workspace = true
2122

crates/stackable-versioned-macros/src/attrs/common/item.rs

Lines changed: 243 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,115 @@
11
use darling::{util::SpannedValue, Error, FromMeta};
22
use k8s_version::Version;
33
use proc_macro2::Span;
4-
use syn::Path;
4+
use syn::{spanned::Spanned, Ident, Path};
5+
6+
use crate::{
7+
attrs::common::ContainerAttributes,
8+
codegen::common::Attributes,
9+
consts::{DEPRECATED_FIELD_PREFIX, DEPRECATED_VARIANT_PREFIX},
10+
};
11+
12+
/// This trait helps to unify attribute validation for both field and variant
13+
/// attributes.
14+
///
15+
/// This trait is implemented using a blanket implementation on types
16+
/// `T: Attributes`. The [`Attributes`] trait allows access to the common
17+
/// attributes shared across field and variant attributes.
18+
pub(crate) trait ValidateVersions<I>
19+
where
20+
I: Spanned,
21+
{
22+
/// Validates that each field action version is present in the declared
23+
/// container versions.
24+
fn validate_versions(
25+
&self,
26+
container_attrs: &ContainerAttributes,
27+
item: &I,
28+
) -> Result<(), darling::Error>;
29+
}
30+
31+
impl<I, T> ValidateVersions<I> for T
32+
where
33+
T: Attributes,
34+
I: Spanned,
35+
{
36+
fn validate_versions(
37+
&self,
38+
container_attrs: &ContainerAttributes,
39+
item: &I,
40+
) -> Result<(), darling::Error> {
41+
// NOTE (@Techassi): Can we maybe optimize this a little?
42+
43+
let mut errors = Error::accumulator();
44+
45+
if let Some(added) = &self.common_attrs().added {
46+
if !container_attrs
47+
.versions
48+
.iter()
49+
.any(|v| v.name == *added.since)
50+
{
51+
errors.push(Error::custom(
52+
"variant action `added` uses version which was not declared via #[versioned(version)]")
53+
.with_span(item)
54+
);
55+
}
56+
}
57+
58+
for rename in &*self.common_attrs().renames {
59+
if !container_attrs
60+
.versions
61+
.iter()
62+
.any(|v| v.name == *rename.since)
63+
{
64+
errors.push(
65+
Error::custom("variant action `renamed` uses version which was not declared via #[versioned(version)]")
66+
.with_span(item)
67+
);
68+
}
69+
}
70+
71+
if let Some(deprecated) = &self.common_attrs().deprecated {
72+
if !container_attrs
73+
.versions
74+
.iter()
75+
.any(|v| v.name == *deprecated.since)
76+
{
77+
errors.push(Error::custom(
78+
"variant action `deprecated` uses version which was not declared via #[versioned(version)]")
79+
.with_span(item)
80+
);
81+
}
82+
}
83+
84+
errors.finish()?;
85+
Ok(())
86+
}
87+
}
88+
89+
// NOTE (@Techassi): It might be possible (but is it required) to move this
90+
// functionality into a shared trait, which knows what type of item 'Self' is.
91+
92+
/// This enum is used to run different validation based on the type of item.
93+
#[derive(Debug, strum::Display)]
94+
#[strum(serialize_all = "lowercase")]
95+
pub(crate) enum ItemType {
96+
Field,
97+
Variant,
98+
}
599

6100
/// These attributes are meant to be used in super structs, which add
7101
/// [`Field`](syn::Field) or [`Variant`](syn::Variant) specific attributes via
8102
/// darling's flatten feature. This struct only provides shared attributes.
103+
///
104+
/// ### Shared Item Rules
105+
///
106+
/// - An item can only ever be added once at most. An item not marked as 'added'
107+
/// is part of the container in every version until renamed or deprecated.
108+
/// - An item can be renamed many times. That's why renames are stored in a
109+
/// [`Vec`].
110+
/// - An item can only be deprecated once. A field not marked as 'deprecated'
111+
/// will be included up until the latest version.
9112
#[derive(Debug, FromMeta)]
10-
#[darling(and_then = ItemAttributes::validate)]
11113
pub(crate) struct ItemAttributes {
12114
/// This parses the `added` attribute on items (fields or variants). It can
13115
/// only be present at most once.
@@ -24,8 +126,12 @@ pub(crate) struct ItemAttributes {
24126
}
25127

26128
impl ItemAttributes {
27-
fn validate(self) -> Result<Self, Error> {
28-
// Validate deprecated options
129+
pub(crate) fn validate(&self, item_ident: &Ident, item_type: &ItemType) -> Result<(), Error> {
130+
// NOTE (@Techassi): This associated function is NOT called by darling's
131+
// and_then attribute, but instead by the wrapper, FieldAttributes and
132+
// VariantAttributes.
133+
134+
let mut errors = Error::accumulator();
29135

30136
// TODO (@Techassi): Make the field 'note' optional, because in the
31137
// future, the macro will generate parts of the deprecation note
@@ -34,12 +140,142 @@ impl ItemAttributes {
34140

35141
if let Some(deprecated) = &self.deprecated {
36142
if deprecated.note.is_empty() {
37-
return Err(Error::custom("deprecation note must not be empty")
38-
.with_span(&deprecated.note.span()));
143+
errors.push(
144+
Error::custom("deprecation note must not be empty")
145+
.with_span(&deprecated.note.span()),
146+
);
39147
}
40148
}
41149

42-
Ok(self)
150+
// Semantic validation
151+
errors.handle(self.validate_action_combinations(item_ident, item_type));
152+
errors.handle(self.validate_action_order(item_ident, item_type));
153+
errors.handle(self.validate_field_name(item_ident, item_type));
154+
155+
// TODO (@Techassi): Add hint if a field is added in the first version
156+
// that it might be clever to remove the 'added' attribute.
157+
158+
errors.finish()?;
159+
160+
Ok(())
161+
}
162+
163+
/// This associated function is called by the top-level validation function
164+
/// and validates that each item uses a valid combination of actions.
165+
/// Invalid combinations are:
166+
///
167+
/// - `added` and `deprecated` using the same version: A field cannot be
168+
/// marked as added in a particular version and then marked as deprecated
169+
/// immediately after. Fields must be included for at least one version
170+
/// before being marked deprecated.
171+
/// - `added` and `renamed` using the same version: The same reasoning from
172+
/// above applies here as well. Fields must be included for at least one
173+
/// version before being renamed.
174+
/// - `renamed` and `deprecated` using the same version: Again, the same
175+
/// rules from above apply here as well.
176+
fn validate_action_combinations(
177+
&self,
178+
item_ident: &Ident,
179+
item_type: &ItemType,
180+
) -> Result<(), Error> {
181+
match (&self.added, &self.renames, &self.deprecated) {
182+
(Some(added), _, Some(deprecated)) if *added.since == *deprecated.since => {
183+
Err(Error::custom(format!(
184+
"{item_type} cannot be marked as `added` and `deprecated` in the same version"
185+
))
186+
.with_span(item_ident))
187+
}
188+
(Some(added), renamed, _) if renamed.iter().any(|r| *r.since == *added.since) => {
189+
Err(Error::custom(format!(
190+
"{item_type} cannot be marked as `added` and `renamed` in the same version"
191+
))
192+
.with_span(item_ident))
193+
}
194+
(_, renamed, Some(deprecated))
195+
if renamed.iter().any(|r| *r.since == *deprecated.since) =>
196+
{
197+
Err(Error::custom(
198+
"field cannot be marked as `deprecated` and `renamed` in the same version",
199+
)
200+
.with_span(item_ident))
201+
}
202+
_ => Ok(()),
203+
}
204+
}
205+
206+
/// This associated function is called by the top-level validation function
207+
/// and validates that actions use a chronologically sound chain of
208+
/// versions.
209+
///
210+
/// The following rules apply:
211+
///
212+
/// - `deprecated` must use a greater version than `added`: This function
213+
/// ensures that these versions are chronologically sound, that means,
214+
/// that the version of the deprecated action must be greater than the
215+
/// version of the added action.
216+
/// - All `renamed` actions must use a greater version than `added` but a
217+
/// lesser version than `deprecated`.
218+
fn validate_action_order(&self, item_ident: &Ident, item_type: &ItemType) -> Result<(), Error> {
219+
let added_version = self.added.as_ref().map(|a| *a.since);
220+
let deprecated_version = self.deprecated.as_ref().map(|d| *d.since);
221+
222+
// First, validate that the added version is less than the deprecated
223+
// version.
224+
// NOTE (@Techassi): Is this already covered by the code below?
225+
if let (Some(added_version), Some(deprecated_version)) = (added_version, deprecated_version)
226+
{
227+
if added_version > deprecated_version {
228+
return Err(Error::custom(format!(
229+
"{item_type} was marked as `added` in version `{added_version}` while being marked as `deprecated` in an earlier version `{deprecated_version}`"
230+
)).with_span(item_ident));
231+
}
232+
}
233+
234+
// Now, iterate over all renames and ensure that their versions are
235+
// between the added and deprecated version.
236+
if !self.renames.iter().all(|r| {
237+
added_version.map_or(true, |a| a < *r.since)
238+
&& deprecated_version.map_or(true, |d| d > *r.since)
239+
}) {
240+
return Err(Error::custom(
241+
"all renames must use versions higher than `added` and lower than `deprecated`",
242+
)
243+
.with_span(item_ident));
244+
}
245+
246+
Ok(())
247+
}
248+
249+
/// This associated function is called by the top-level validation function
250+
/// and validates that items use correct names depending on attached
251+
/// actions.
252+
///
253+
/// The following naming rules apply:
254+
///
255+
/// - Fields marked as deprecated need to include the 'deprecated_' prefix
256+
/// in their name. The prefix must not be included for fields which are
257+
/// not deprecated.
258+
fn validate_field_name(&self, item_ident: &Ident, item_type: &ItemType) -> Result<(), Error> {
259+
let prefix = match item_type {
260+
ItemType::Field => DEPRECATED_FIELD_PREFIX,
261+
ItemType::Variant => DEPRECATED_VARIANT_PREFIX,
262+
};
263+
264+
let starts_with_deprecated = item_ident.to_string().starts_with(prefix);
265+
266+
if self.deprecated.is_some() && !starts_with_deprecated {
267+
return Err(Error::custom(
268+
format!("{item_type} was marked as `deprecated` and thus must include the `{prefix}` prefix in its name")
269+
).with_span(item_ident));
270+
}
271+
272+
if self.deprecated.is_none() && starts_with_deprecated {
273+
return Err(Error::custom(
274+
format!("{item_type} includes the `{prefix}` prefix in its name but is not marked as `deprecated`")
275+
).with_span(item_ident));
276+
}
277+
278+
Ok(())
43279
}
44280
}
45281

0 commit comments

Comments
 (0)