Skip to content
Open
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: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,29 @@ and `__private::arbitrary_bytes`, none of which exist in `buffa` 0.4.0.

### Added

- Generated message structs now include `with_<field>(value) -> Self` builder
methods for every explicit-presence scalar, bytes, and enum field (proto3
`optional`, proto2 `optional`, and editions fields with
`field_presence = EXPLICIT`). This allows chained construction without
`Some(...)` wrapping:

```rust
let req = GetSecretRequest::default()
.with_name("alice")
.with_timeout_ms(30_000)
.with_enabled(true);
```

String fields accept `impl Into<String>` (`&str` works directly); bytes
fields accept `impl Into<Vec<u8>>` or `impl Into<bytes::Bytes>` (byte
array literals like `b"data"` work directly); enum fields accept
`impl Into<EnumValue<E>>` (bare variant works directly, no
`EnumValue::Known(...)` wrapper needed); plain scalars take the bare
type. Message fields (`MessageField<T>`), repeated fields, map fields,
oneof variants, proto2 `required` fields, and implicit-presence fields
are unaffected. To clear a field, assign `None` directly.
Controlled by `CodeGenConfig::generate_with_setters` (default `true`).
([#30](https://github.com/anthropics/buffa/issues/30))
- `buffa_codegen::GeneratedFileKind::Companion` and `apply_companions` let
downstream code generators (e.g. connect-rust) supply extra per-proto
files that buffa wires into the per-package stitcher, instead of having
Expand Down
25 changes: 25 additions & 0 deletions buffa-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,30 @@ pub struct CodeGenConfig {
/// `#[derive(strum::EnumIter)]` when the user does not want to apply the
/// same attribute to every message in the matched scope.
pub enum_attributes: Vec<(String, String)>,
/// Generate `with_*` builder-style setter methods for explicit-presence fields.
///
/// Each explicit-presence scalar, bytes, or enum field gets a
/// `pub fn with_<name>(mut self, value: T) -> Self` method that wraps the
/// value in `Some` and returns `self`, enabling chained construction:
///
/// ```ignore
/// let req = MyRequest::default()
/// .with_name("alice")
/// .with_timeout_ms(30_000);
/// ```
///
/// **Fields that receive a setter:** proto3 `optional`, proto2 `optional`,
/// and editions fields with `field_presence = EXPLICIT`.
///
/// **Fields that do not receive a setter:** message fields
/// (`MessageField<T>`), repeated fields, map fields, oneof variant fields,
/// proto2 `required` fields, and any implicit-presence field.
///
/// There is no `clear_<name>` companion — to clear a field, assign `None`
/// directly: `msg.name = None;`.
///
/// Defaults to `true`.
pub generate_with_setters: bool,
}

impl Default for CodeGenConfig {
Expand All @@ -320,6 +344,7 @@ impl Default for CodeGenConfig {
field_attributes: Vec::new(),
message_attributes: Vec::new(),
enum_attributes: Vec::new(),
generate_with_setters: true,
}
}
}
Expand Down
83 changes: 83 additions & 0 deletions buffa-codegen/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,34 @@ fn generate_message_with_nesting(
// Collect field identifiers for the manual Debug impl (excludes __buffa_ internals).
let mut debug_field_idents: Vec<&Ident> = generated_fields.iter().map(|f| &f.ident).collect();

let setter_methods: TokenStream = generated_fields
.iter()
.filter_map(|f| f.setter.as_ref().map(|s| (f, s)))
.map(|(f, s)| {
let field_ident = &f.ident;
let setter_ident = &s.ident;
let field_name = field_ident.to_string();
let doc = format!(
"Sets [`Self::{field_name}`] to `Some(value)`, consuming and returning `self`."
);
let body = if s.use_into {
quote! { Some(value.into()) }
} else {
quote! { Some(value) }
};
let param = &s.param_type;
quote! {
#[must_use = "with_* setters return `self` by value; assign or chain the result"]
#[inline]
#[doc = #doc]
pub fn #setter_ident(mut self, value: #param) -> Self {
self.#field_ident = #body;
self
}
}
})
.collect();

// Module name for this message (snake_case of proto name).
let proto_name = msg.name.as_deref().unwrap_or(rust_name);
let mod_name_str = crate::oneof::to_snake_case(proto_name);
Expand Down Expand Up @@ -649,6 +677,16 @@ fn generate_message_with_nesting(
let message_doc =
crate::comments::doc_attrs_resolved(ctx.comment(proto_fqn), proto_fqn, &ctx.type_map);

let with_setters_impl = if ctx.config.generate_with_setters && !setter_methods.is_empty() {
quote! {
impl #name_ident {
#setter_methods
}
}
} else {
quote! {}
};

let top_level = quote! {
#message_doc
#[derive(Clone, PartialEq, #derive_default)]
Expand All @@ -674,6 +712,8 @@ fn generate_message_with_nesting(
pub const TYPE_URL: &'static str = #type_url;
}

#with_setters_impl

#message_impl

#text_impl
Expand Down Expand Up @@ -1228,6 +1268,9 @@ struct FieldInfo {
/// features — that field is TYPE_MESSAGE so the overlay doesn't fire.
/// See `map_serde_module`.
map_value_enum_closed: Option<bool>,
/// The bare inner type `T` when `is_optional = true` (`rust_type` is `Option<T>`).
/// `None` for all non-optional fields.
inner_opt_type: Option<TokenStream>,
}

/// Resolve the Rust type and map-entry metadata for a single field.
Expand Down Expand Up @@ -1272,6 +1315,7 @@ fn classify_field(
quote! { #vec<u8> }
};

let mut inner_opt_type: Option<TokenStream> = None;
let rust_type = if let Some(entry) = map_entry {
map_rust_type_from_entry(scope, entry, resolver)?
} else if is_repeated {
Expand All @@ -1298,6 +1342,7 @@ fn classify_field(
} else {
scalar_rust_type(field_type, resolver)?
};
inner_opt_type = Some(inner.clone());
{
let opt = resolver.option();
quote! { #opt<#inner> }
Expand Down Expand Up @@ -1360,9 +1405,19 @@ fn classify_field(
map_key_type,
map_value_type,
map_value_enum_closed,
inner_opt_type,
})
}

/// Setter method info for a single explicit-presence field.
struct SetterInfo {
ident: Ident,
param_type: TokenStream,
/// `true` → emit `Some(value.into())` (String, bytes::Bytes)
/// `false` → emit `Some(value)` (scalars, enums, Vec<u8>)
use_into: bool,
}

/// Generate a single field declaration.
///
/// Returns `None` for fields that belong to a real oneof — those are
Expand All @@ -1372,6 +1427,7 @@ fn classify_field(
struct GeneratedField {
tokens: TokenStream,
ident: Ident,
setter: Option<SetterInfo>,
}

fn generate_field(
Expand Down Expand Up @@ -1436,9 +1492,36 @@ fn generate_field(
#custom_field_attrs
pub #rust_name: #rust_type,
};

// Use inner_opt_type as the gate: it is set only when classify_field
// actually took the is_optional branch (i.e. the struct field is Option<T>).
// is_optional alone is not sufficient — proto2 repeated fields can have
// is_optional=true (explicit-presence default) while is_repeated=true.
let setter = if let Some(inner) = &info.inner_opt_type {
let field_type = crate::impl_message::effective_type(ctx, field, features);
let setter_ident = format_ident!("with_{}", field_name);
// impl Into<T> where a common conversion exists:
// String: &str. Vec<u8>: &[u8; N] (From<&[T; N]> stable since Rust 1.74).
// bytes::Bytes: Vec<u8>. EnumValue<E>: E (From<E> impl on EnumValue).
let (param_type, use_into) = match field_type {
Type::TYPE_STRING | Type::TYPE_BYTES | Type::TYPE_ENUM => {
(quote! { impl Into<#inner> }, true)
}
_ => (quote! { #inner }, false),
};
Some(SetterInfo {
ident: setter_ident,
param_type,
use_into,
})
} else {
None
};

Ok(Some(GeneratedField {
tokens,
ident: rust_name,
setter,
}))
}

Expand Down
168 changes: 168 additions & 0 deletions buffa-codegen/tests/codegen_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -853,3 +853,171 @@ fn inline_empty_message_no_unknown_fields() {
assert!(content.contains("pub struct Empty"));
assert!(!content.contains("__buffa_unknown_fields"));
}

// ── with_* setter tests ───────────────────────────────────────────────────────

#[test]
fn with_setters_emitted_for_explicit_presence_fields() {
let content = generate_proto(
r#"
syntax = "proto3";
package test;
enum Color { COLOR_UNSPECIFIED = 0; RED = 1; }
message Msg {
optional int32 count = 1;
optional string name = 2;
optional bytes data = 3;
optional Color color = 4;
// implicit-presence fields — no setter
int32 implicit_int = 5;
string implicit_str = 6;
// repeated — no setter
repeated string tags = 7;
}
"#,
&no_views(),
);

// Explicit-presence fields get setters.
assert!(
content.contains("pub fn with_count"),
"with_count missing: {content}"
);
assert!(
content.contains("pub fn with_name"),
"with_name missing: {content}"
);
assert!(
content.contains("pub fn with_data"),
"with_data missing: {content}"
);
assert!(
content.contains("pub fn with_color"),
"with_color missing: {content}"
);

// String setter uses impl Into<...> for &str ergonomics.
assert!(
content.contains("impl Into"),
"string setter should use impl Into: {content}"
);

// Implicit-presence and repeated fields must NOT get setters.
assert!(
!content.contains("with_implicit_int"),
"implicit int should not get setter: {content}"
);
assert!(
!content.contains("with_implicit_str"),
"implicit string should not get setter: {content}"
);
assert!(
!content.contains("with_tags"),
"repeated field should not get setter: {content}"
);
}

#[test]
fn with_setters_disabled_by_config() {
let mut config = no_views();
config.generate_with_setters = false;
let content = generate_proto(
r#"
syntax = "proto3";
package test;
message Msg { optional int32 count = 1; }
"#,
&config,
);
assert!(
!content.contains("pub fn with_count"),
"setter should be absent when generate_with_setters=false: {content}"
);
}

#[test]
fn with_setters_bytes_type_uses_into() {
let mut config = no_views();
config.bytes_fields.push(".test.Msg.data".into());
let content = generate_proto(
r#"
syntax = "proto3";
package test;
message Msg { optional bytes data = 1; }
"#,
&config,
);
// bytes::Bytes field should use impl Into for ergonomics.
assert!(
content.contains("pub fn with_data"),
"with_data missing: {content}"
);
assert!(
content.contains("impl Into"),
"bytes::Bytes setter should use impl Into: {content}"
);
}

#[test]
fn with_setters_vec_u8_uses_into() {
let content = generate_proto(
r#"
syntax = "proto3";
package test;
message Msg { optional bytes data = 1; }
"#,
&no_views(),
);
// Vec<u8> bytes field uses impl Into: From<&[T; N]> for Vec<T> is stable
// since Rust 1.74, so b"hello" works directly without .to_vec().
assert!(
content.contains("pub fn with_data"),
"with_data missing: {content}"
);
assert!(
content.contains("impl Into"),
"Vec<u8> setter should use impl Into: {content}"
);
}

#[test]
fn with_setters_enum_uses_into() {
let content = generate_proto(
r#"
syntax = "proto3";
package test;
enum Color { COLOR_UNSPECIFIED = 0; RED = 1; }
message Msg { optional Color color = 1; }
"#,
&no_views(),
);
// EnumValue<E>: From<E>, so impl Into<EnumValue<E>> lets callers pass the
// enum variant directly without wrapping in EnumValue::Known(...).
assert!(
content.contains("pub fn with_color"),
"with_color missing: {content}"
);
assert!(
content.contains("impl Into"),
"enum setter should use impl Into: {content}"
);
}

#[test]
fn with_setters_proto2_repeated_no_setter() {
// Regression: proto2 repeated fields have is_explicit_presence=true due to
// proto2's EXPLICIT presence default, but their struct field is Vec<T>,
// not Option<T>. They must not receive a setter.
let content = generate_proto(
r#"
syntax = "proto2";
package test;
message Msg { repeated string items = 1; }
"#,
&no_views(),
);
assert!(
!content.contains("with_items"),
"proto2 repeated field should not get setter: {content}"
);
}
Loading