Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
kind: Added
body: |-
**`Config::oneof_struct_field_attribute` / `CodeGenConfig::oneof_struct_field_attributes`** (#561) put a custom attribute on the message struct's field holding a oneof (the `Option<OneofEnum>` member), matched against the oneof's path (`.pkg.Message.oneof_name`). `field_attribute` never reached that field: on the oneof's path it matches only the variants, where an attribute such as `#[serde(skip_serializing_if = "...")]` is rejected. prost-build puts such a `field_attribute` on the struct field as well. A `#[deprecated]` given through `oneof_struct_field_attribute` marks the owned struct's field only, and the generated items that visit it carry `#[allow(deprecated)]`.
time: 2026-10-04T21:00:00.000000000+00:00
122 changes: 120 additions & 2 deletions buffa-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1812,7 +1812,10 @@ impl Config {
/// Prefix matching respects proto-segment boundaries.
///
/// Also applies to oneof variants when `path` matches
/// `".pkg.Msg.my_oneof.variant_name"`.
/// `".pkg.Msg.my_oneof.variant_name"`, but not to the struct field holding
/// the oneof; use
/// [`oneof_struct_field_attribute`](Self::oneof_struct_field_attribute) for
/// that.
///
/// A `#[deprecated]` supplied here wins over the one codegen derives from
/// the field's `[deprecated = true]` option — rustc permits only one
Expand Down Expand Up @@ -1931,7 +1934,9 @@ impl Config {
/// order. The match key is the oneof's fully-qualified path
/// (`.my.pkg.MyMessage.my_oneof`) — the whole-enum path has no variant
/// segment; to target a single variant's field, append `.variant_name`
/// and use [`field_attribute`](Self::field_attribute) instead. A
/// and use [`field_attribute`](Self::field_attribute) instead, and for the
/// message struct's field holding the oneof, use
/// [`oneof_struct_field_attribute`](Self::oneof_struct_field_attribute). A
/// malformed attribute produces a compile-time error in the generated
/// code. Useful when a oneof needs a different attribute set than the
/// surrounding types — for example to keep `#[derive(serde::Serialize)]`
Expand Down Expand Up @@ -1976,6 +1981,100 @@ impl Config {
self
}

/// Add a custom attribute to the message struct's field holding a oneof
/// (the `Option<OneofEnum>` member), not to the oneof's variants.
///
/// The match key is the oneof's fully-qualified path
/// (`.my.pkg.MyMessage.my_oneof`), with the same path-matching semantics
/// as [`type_attribute`](Self::type_attribute): `".my.pkg"` reaches the
/// oneof field of every message in the package, nested ones included.
/// Attributes accumulate in insertion order, after buffa's own attributes
/// on the field. A malformed attribute produces a compile-time error.
///
/// A rule on a variant's path (`.my.pkg.MyMessage.my_oneof.variant_name`)
/// does not match the oneof, so it has no effect, and the build does not
/// warn about it. Variants take their attributes from
/// [`field_attribute`](Self::field_attribute).
///
/// Each of these lines of the generated code for a oneof takes its
/// attributes from a different method:
///
/// ```rust,ignore
/// pub struct Event {
/// // oneof_struct_field_attribute(".pkg.Event.payload", ..)
/// pub payload: Option<event::Payload>,
/// }
///
/// // oneof_attribute(".pkg.Event.payload", ..), or type_attribute on the same path
/// pub enum Payload {
/// // field_attribute(".pkg.Event.payload.text", ..)
/// Text(String),
/// }
/// ```
///
/// `field_attribute` on the oneof's path (`.pkg.Event.payload`) matches
/// the variants only, where an attribute
/// such as `#[serde(skip_serializing_if = "...")]` is rejected. prost-build
/// puts such a `field_attribute` on the struct field as well; use this
/// method for the struct field.
///
/// Applies to the owned message struct only; the view structs do not get
/// the attribute.
///
/// A `#[deprecated]` given here marks the owned struct's field only. The
/// same field on the view structs stays unmarked, so reading the oneof
/// through a view does not warn, unlike a field deprecated through
/// `field_attribute`. The generated items that visit the owned field
/// carry `#[allow(deprecated)]`.
///
/// # Pitfalls
///
/// With [`generate_json(true)`](Self::generate_json) the field already
/// carries buffa's `#[serde(flatten)]` for the derived `Serialize`, and
/// buffa generates the `Deserialize` impl of a message with a oneof
/// instead of deriving it. So a serde attribute given here changes the
/// owned message's `Serialize` output only: `Deserialize` and the views'
/// `Serialize` ignore it. A second `flatten` is a compile error in the
/// generated code. Serde attributes here are for a serde derive that you
/// attach yourself, with `generate_json` off.
///
/// With
/// [`gate_impls_on_crate_features(true)`](Self::gate_impls_on_crate_features)
/// buffa's serde derive is compiled only under the JSON feature. Write a
/// serde attribute for it as `#[cfg_attr(feature = "json", serde(...))]`,
/// with your JSON feature's name if you renamed it, so that it is compiled
/// under the same feature.
///
/// # Example
///
/// ```rust,ignore
/// buffa_build::Config::new()
/// // `UnknownFields` does not implement `serde::Serialize`. With this
/// // off, unknown fields are dropped on decode.
/// .preserve_unknown_fields(false)
/// .message_attribute(".my.pkg.MyMessage", "#[derive(serde::Serialize)]")
/// .oneof_attribute(".my.pkg.MyMessage.my_oneof", "#[derive(serde::Serialize)]")
/// .oneof_struct_field_attribute(
/// ".my.pkg.MyMessage.my_oneof",
/// "#[serde(skip_serializing_if = \"Option::is_none\")]",
/// )
/// .files(&["proto/my_service.proto"])
/// .includes(&["proto/"])
/// .compile()
/// .unwrap();
/// ```
#[must_use]
pub fn oneof_struct_field_attribute(
mut self,
path: impl Into<String>,
attribute: impl Into<String>,
) -> Self {
self.codegen_config
.oneof_struct_field_attributes
.push((normalize_attr_path(path.into()), attribute.into()));
self
}

/// Use `buf build` instead of `protoc` for descriptor generation.
///
/// `buf` is often easier to install and keep current than `protoc`
Expand Down Expand Up @@ -3716,6 +3815,24 @@ mod tests {
assert!(cfg.codegen_config.field_attributes.is_empty());
}

#[test]
fn oneof_struct_field_attribute_forwards_normalized_path() {
let cfg =
Config::new().oneof_struct_field_attribute("my.pkg.Msg.payload.", "#[serde(skip)]");
assert_eq!(
cfg.codegen_config.oneof_struct_field_attributes,
vec![(
".my.pkg.Msg.payload".to_string(),
"#[serde(skip)]".to_string()
)]
);
assert!(cfg.codegen_config.oneof_attributes.is_empty());
assert!(cfg.codegen_config.field_attributes.is_empty());
assert!(cfg.codegen_config.type_attributes.is_empty());
assert!(cfg.codegen_config.message_attributes.is_empty());
assert!(cfg.codegen_config.enum_attributes.is_empty());
}

#[test]
fn oneof_attribute_forwards_normalized_path() {
let cfg = Config::new().oneof_attribute("my.pkg.Msg.payload.", "#[derive(Hash)]");
Expand All @@ -3731,6 +3848,7 @@ mod tests {
assert!(cfg.codegen_config.enum_attributes.is_empty());
assert!(cfg.codegen_config.message_attributes.is_empty());
assert!(cfg.codegen_config.field_attributes.is_empty());
assert!(cfg.codegen_config.oneof_struct_field_attributes.is_empty());
}

#[test]
Expand Down
23 changes: 20 additions & 3 deletions buffa-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1505,7 +1505,10 @@ pub struct CodeGenConfig {
///
/// Each entry is `(proto_path, attribute)`. The `proto_path` is matched
/// as a prefix against the fully-qualified field path (e.g.,
/// `".my.pkg.MyMessage.my_field"`). `"."` applies to all fields.
/// `".my.pkg.MyMessage.my_field"`). `"."` applies to all fields. Oneof
/// variants are matched as `".my.pkg.MyMessage.my_oneof.variant"`; the
/// struct field holding the oneof is not reached, see
/// `oneof_struct_field_attributes`.
pub field_attributes: Vec<(String, String)>,
/// Custom attributes to inject on generated message structs only (not enums).
///
Expand All @@ -1530,6 +1533,18 @@ pub struct CodeGenConfig {
/// separate `enum_attributes` entry puts a different serde derive on the
/// regular enums.
pub oneof_attributes: Vec<(String, String)>,
/// Custom attributes to inject on a message struct's field holding a
/// oneof (the `Option<OneofEnum>` member), not on the oneof's variants or
/// the oneof enum.
///
/// Same path-matching semantics as `type_attributes`, matched against the
/// oneof's fully-qualified path (`.pkg.Message.oneof_name`).
/// `field_attributes` never reaches this field: on the oneof's path it
/// matches only the variants (`.pkg.Message.oneof_name.variant`).
///
/// Applies to the owned message struct only; the view structs do not get
/// the attribute.
pub oneof_struct_field_attributes: Vec<(String, String)>,
/// Wrap generated `impl`s in `#[cfg(feature = "...")]` instead of
/// emitting them unconditionally.
///
Expand Down Expand Up @@ -1985,6 +2000,7 @@ impl Default for CodeGenConfig {
message_attributes: Vec::new(),
enum_attributes: Vec::new(),
oneof_attributes: Vec::new(),
oneof_struct_field_attributes: Vec::new(),
gate_impls_on_crate_features: false,
generate_with_setters: true,
generate_reflection: false,
Expand Down Expand Up @@ -5372,8 +5388,9 @@ pub enum CodeGenError {
MessageSetNotSupported { message_name: String },
/// A custom attribute string configured via [`CodeGenConfig::type_attributes`],
/// [`CodeGenConfig::field_attributes`], [`CodeGenConfig::message_attributes`],
/// [`CodeGenConfig::enum_attributes`], or [`CodeGenConfig::oneof_attributes`]
/// could not be parsed as a Rust attribute.
/// [`CodeGenConfig::enum_attributes`], [`CodeGenConfig::oneof_attributes`],
/// or [`CodeGenConfig::oneof_struct_field_attributes`] could not be parsed
/// as a Rust attribute.
#[error(
"invalid custom attribute for path '{path}': '{attribute}' is not a valid \
Rust attribute ({detail})"
Expand Down
67 changes: 43 additions & 24 deletions buffa-codegen/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,26 +276,29 @@ fn generate_message_with_nesting(
} else {
quote! {}
};
let oneof_generated: Vec<(TokenStream, Ident)> = msg
.oneof_decl
.iter()
.enumerate()
.filter_map(|(idx, oneof)| {
let enum_ident = oneof_idents.get(&idx)?;
let oneof_name = oneof.name.as_deref()?;
let field_ident = ctx.oneof_ident(oneof_name);
let opt = resolver.option_at(ctx, nesting);
let rename_note = ctx
.oneof_rename_note(oneof_name)
.map(|note| quote! { #[doc = #note] });
let tokens = quote! {
#rename_note
#oneof_serde_attr
pub #field_ident: #opt<#oneof_prefix #enum_ident>,
};
Some((tokens, field_ident))
})
.collect();
let mut oneof_generated: Vec<(TokenStream, Ident)> = Vec::new();
for (idx, oneof) in msg.oneof_decl.iter().enumerate() {
let (Some(enum_ident), Some(oneof_name)) = (oneof_idents.get(&idx), oneof.name.as_deref())
else {
continue;
};
let field_ident = ctx.oneof_ident(oneof_name);
let opt = resolver.option_at(ctx, nesting);
let rename_note = ctx
.oneof_rename_note(oneof_name)
.map(|note| quote! { #[doc = #note] });
let custom_attrs = CodeGenContext::matching_attributes(
&ctx.config.oneof_struct_field_attributes,
&format!("{proto_fqn}.{oneof_name}"),
)?;
let tokens = quote! {
#rename_note
#oneof_serde_attr
#custom_attrs
pub #field_ident: #opt<#oneof_prefix #enum_ident>,
};
oneof_generated.push((tokens, field_ident));
}
let oneof_struct_fields: Vec<&TokenStream> = oneof_generated.iter().map(|(t, _)| t).collect();
// Redaction of oneof payloads is handled by the oneof enum's own Debug impl.
debug_fields.extend(oneof_generated.iter().map(|(_, id)| (id, false)));
Expand Down Expand Up @@ -2158,11 +2161,17 @@ pub(crate) fn is_deprecated(field: &crate::generated::descriptor::FieldDescripto
/// of it. Unparseable attribute strings are ignored here — they are reported
/// by [`CodeGenContext::matching_attributes`] on the way to the same field.
pub(crate) fn caller_deprecated_attr(ctx: &CodeGenContext, fqn: &str) -> bool {
if ctx.config.field_attributes.is_empty() {
rules_deprecate(&ctx.config.field_attributes, fqn)
}

/// True when one of the caller's `(path, attribute)` rules matches `fqn` and
/// carries a `#[deprecated]` marker.
fn rules_deprecate(rules: &[(String, String)], fqn: &str) -> bool {
if rules.is_empty() {
return false;
}
let fqn_dotted = format!(".{fqn}");
ctx.config.field_attributes.iter().any(|(prefix, attr)| {
rules.iter().any(|(prefix, attr)| {
crate::context::matches_proto_prefix(prefix, &fqn_dotted) && is_deprecated_attr_str(attr)
})
}
Expand Down Expand Up @@ -2268,11 +2277,21 @@ pub(crate) fn default_names_deprecated_value(

/// Whether generated code for `msg` touches anything marked deprecated: one of
/// its own non-oneof fields (from the option or from a caller's
/// `field_attribute`), or an enum variant named by a field's `[default = …]`.
/// `field_attribute`), a struct field holding a oneof (from a caller's
/// `oneof_struct_field_attribute`), or an enum variant named by a field's
/// `[default = …]`.
fn references_deprecated(ctx: &CodeGenContext, msg: &DescriptorProto, proto_fqn: &str) -> bool {
let oneof_rules = &ctx.config.oneof_struct_field_attributes;
msg.field.iter().any(|f| {
if crate::impl_message::is_real_oneof_member(f) {
return false;
// The member is a variant; the struct field is its oneof's.
return !oneof_rules.is_empty()
&& f.oneof_index
.and_then(|idx| msg.oneof_decl.get(usize::try_from(idx).ok()?))
.and_then(|oneof| oneof.name.as_deref())
.is_some_and(|name| {
rules_deprecate(oneof_rules, &format!("{proto_fqn}.{name}"))
});
}
let field_fqn = format!("{proto_fqn}.{}", f.name.as_deref().unwrap_or_default());
field_is_deprecated(ctx, f, &field_fqn) || default_names_deprecated_value(ctx, f)
Expand Down
Loading
Loading