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
31 changes: 31 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,37 @@ let view = OwnedView::<PersonView>::decode(bytes)?;
println!("name: {}", view.name); // Deref, zero-copy, 'static + Send
```

**Generated code layout — parallel trees per kind:**

Ancillary generated types live in **kind-namespaced parallel trees** at the package root, not interleaved with the owned messages. The kind segment is outermost; for kinds that modify other kinds (only `view` today), the modifier wraps the modified:

```text
<package>::<proto-path>::<ident> # owned messages / enums
<package>::<kind>::<proto-path>::<ident> # ancillary kind
<package>::<modifier>::<kind>::<proto-path>::<ident> # modifier wrapping kind (view of oneofs)
```

Current kinds are `view`, `oneofs`, and `ext`; only `view` is a modifier. Concretely, for proto package `my.pkg` containing message `Foo` with oneof `bar` and extension `baz`:

| Item | Generated path |
|----------------------------|---------------------------------------------|
| Owned message struct | `my::pkg::Foo` |
| View message struct | `my::pkg::view::FooView<'a>` |
| Oneof enum (owned) | `my::pkg::oneofs::foo::Bar` |
| Oneof enum (view) | `my::pkg::view::oneofs::foo::Bar<'a>` |
| File-level extension const | `my::pkg::ext::BAZ` |

Two things to note:

- The **owner module** in the `oneofs::` tree (`foo` above) is snake-cased from the owner message name. The oneof enum keeps its PascalCase proto name (`Bar`) — no `Kind` suffix, no `View` suffix on view-of-oneof enums. The tree prefix (`oneofs::` vs `view::oneofs::`) disambiguates.
- **View message structs keep the `View` suffix** (`FooView<'a>`) even though they live in `view::`. This is a deliberate exception: users routinely import the owned type and the view type together (`use pkg::{Foo, FooView}`) and a bare `View` would shadow too commonly.

This layout has three structural benefits:

1. **Collision-free.** A oneof whose owner name matches an existing nested type / enum / extension can't fight over the package-root namespace, because each lives in a different kind tree. The codegen has no reserved-name escape hatch; it simply emits verbatim proto names.
2. **Predictable.** Every proto package emits exactly five sibling files per `.proto`: `<stem>.rs`, `<stem>.__view.rs`, `<stem>.__ext.rs`, `<stem>.__oneofs.rs`, `<stem>.__view_oneofs.rs` — empty-bodied when the kind has no content. `buffa-build` and the packaging plugin stitch these into `pub mod view { … pub mod oneofs { … } }`, `pub mod ext { … }`, and `pub mod oneofs { … }` wrappers per package.
3. **Feature-gatable.** View and extension support are codegen options; disabling them simply leaves those trees empty. The structural layout is stable regardless.

### 3. MessageField\<T\> — Ergonomic Optional Messages

Prost uses `Option<Box<M>>` for optional message fields, which creates unwrapping ceremony everywhere:
Expand Down
155 changes: 123 additions & 32 deletions buffa-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,29 +589,18 @@ impl Config {
let generated =
buffa_codegen::generate(&fds.file, &files_to_generate, &self.codegen_config)?;

// Build a map from generated file name to proto package for the
// module tree generator.
let file_to_package: std::collections::HashMap<String, String> = fds
.file
.iter()
.map(|fd| {
let proto_name = fd.name.as_deref().unwrap_or("");
let rs_name = buffa_codegen::proto_path_to_rust_module(proto_name);
let package = fd.package.as_deref().unwrap_or("").to_string();
(rs_name, package)
})
.collect();

// Write output files and collect (name, package) pairs.
let mut output_entries: Vec<(String, String)> = Vec::new();
// Write output files and collect (name, package, kind) entries.
// Package/kind now come directly from `GeneratedFile`, so no
// separate lookup table is needed.
let mut output_entries: Vec<(String, String, buffa_codegen::GeneratedFileKind)> =
Vec::new();
for file in generated {
let path = out_dir.join(&file.name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
write_if_changed(&path, file.content.as_bytes())?;
let package = file_to_package.get(&file.name).cloned().unwrap_or_default();
output_entries.push((file.name, package));
output_entries.push((file.name, file.package, file.kind));
}

// Generate the include file if requested.
Expand Down Expand Up @@ -824,7 +813,11 @@ fn proto_relative_name(file: &Path, includes: &[PathBuf]) -> String {
/// `include!` directives use bare sibling paths (`include!("foo.bar.rs")`)
/// instead of the `env!("OUT_DIR")` prefix, so the include file works when
/// checked into the source tree and referenced via `mod`.
fn generate_include_file(entries: &[(String, String)], relative: bool) -> String {
fn generate_include_file(
entries: &[(String, String, buffa_codegen::GeneratedFileKind)],
relative: bool,
) -> String {
use buffa_codegen::GeneratedFileKind;
use std::collections::BTreeMap;
use std::fmt::Write;

Expand All @@ -850,12 +843,16 @@ fn generate_include_file(entries: &[(String, String)], relative: bool) -> String

#[derive(Default)]
struct ModNode {
files: Vec<String>,
owned_files: Vec<String>,
view_files: Vec<String>,
ext_files: Vec<String>,
oneofs_files: Vec<String>,
view_oneofs_files: Vec<String>,
children: BTreeMap<String, Self>,
}

let mut root = ModNode::default();
for (file_name, package) in entries {
for (file_name, package, kind) in entries {
let pkg_parts: Vec<&str> = if package.is_empty() {
vec![]
} else {
Expand All @@ -865,25 +862,92 @@ fn generate_include_file(entries: &[(String, String)], relative: bool) -> String
for seg in &pkg_parts {
node = node.children.entry(seg.to_string()).or_default();
}
node.files.push(file_name.clone());
match kind {
GeneratedFileKind::Owned => node.owned_files.push(file_name.clone()),
GeneratedFileKind::View => node.view_files.push(file_name.clone()),
GeneratedFileKind::Ext => node.ext_files.push(file_name.clone()),
GeneratedFileKind::Oneofs => node.oneofs_files.push(file_name.clone()),
GeneratedFileKind::ViewOneofs => node.view_oneofs_files.push(file_name.clone()),
}
}

let mut out = String::new();
writeln!(out, "// @generated by buffa-build. DO NOT EDIT.").unwrap();
writeln!(out).unwrap();

fn write_include(out: &mut String, indent: &str, file: &str, relative: bool) {
use std::fmt::Write as _;
if relative {
writeln!(out, r#"{indent}include!("{file}");"#).unwrap();
} else {
writeln!(
out,
r#"{indent}include!(concat!(env!("OUT_DIR"), "/{file}"));"#
)
.unwrap();
}
}

fn emit(out: &mut String, node: &ModNode, depth: usize, relative: bool) {
let indent = " ".repeat(depth);
for file in &node.files {
if relative {
writeln!(out, r#"{indent}include!("{file}");"#).unwrap();
} else {
for file in &node.owned_files {
write_include(out, &indent, file, relative);
}
// `pub mod view { … pub mod oneofs { … } }` — the view-oneofs
// modifier wraps the oneofs kind (DESIGN.md → "Generated code
// layout"). Multi-file packages coalesce into one wrapper each.
let emit_view = !node.view_files.is_empty() || !node.view_oneofs_files.is_empty();
if emit_view {
writeln!(
out,
"{indent}#[allow(non_camel_case_types, dead_code, unused_imports, \
clippy::derivable_impls, clippy::match_single_binding)]"
)
.unwrap();
writeln!(out, "{indent}pub mod view {{").unwrap();
for file in &node.view_files {
write_include(out, &format!("{indent} "), file, relative);
}
if !node.view_oneofs_files.is_empty() {
writeln!(
out,
r#"{indent}include!(concat!(env!("OUT_DIR"), "/{file}"));"#
"{indent} #[allow(non_camel_case_types, dead_code, unused_imports, \
clippy::derivable_impls, clippy::match_single_binding)]"
)
.unwrap();
writeln!(out, "{indent} pub mod oneofs {{").unwrap();
for file in &node.view_oneofs_files {
write_include(out, &format!("{indent} "), file, relative);
}
writeln!(out, "{indent} }}").unwrap();
}
writeln!(out, "{indent}}}").unwrap();
}
if !node.ext_files.is_empty() {
writeln!(
out,
"{indent}#[allow(non_camel_case_types, dead_code, unused_imports, \
clippy::derivable_impls, clippy::match_single_binding)]"
)
.unwrap();
writeln!(out, "{indent}pub mod ext {{").unwrap();
for file in &node.ext_files {
write_include(out, &format!("{indent} "), file, relative);
}
writeln!(out, "{indent}}}").unwrap();
}
if !node.oneofs_files.is_empty() {
writeln!(
out,
"{indent}#[allow(non_camel_case_types, dead_code, unused_imports, \
clippy::derivable_impls, clippy::match_single_binding)]"
)
.unwrap();
writeln!(out, "{indent}pub mod oneofs {{").unwrap();
for file in &node.oneofs_files {
write_include(out, &format!("{indent} "), file, relative);
}
writeln!(out, "{indent}}}").unwrap();
}
for (name, child) in &node.children {
let escaped = escape_mod_name(name);
Expand Down Expand Up @@ -954,9 +1018,18 @@ mod tests {

#[test]
fn include_file_out_dir_mode_uses_env_var() {
use buffa_codegen::GeneratedFileKind;
let entries = vec![
("foo.bar.rs".to_string(), "foo".to_string()),
("root.rs".to_string(), String::new()),
(
"foo.bar.rs".to_string(),
"foo".to_string(),
GeneratedFileKind::Owned,
),
(
"root.rs".to_string(),
String::new(),
GeneratedFileKind::Owned,
),
];
let out = generate_include_file(&entries, false);
assert!(
Expand All @@ -972,9 +1045,18 @@ mod tests {

#[test]
fn include_file_relative_mode_uses_sibling_paths() {
use buffa_codegen::GeneratedFileKind;
let entries = vec![
("foo.bar.rs".to_string(), "foo".to_string()),
("root.rs".to_string(), String::new()),
(
"foo.bar.rs".to_string(),
"foo".to_string(),
GeneratedFileKind::Owned,
),
(
"root.rs".to_string(),
String::new(),
GeneratedFileKind::Owned,
),
];
let out = generate_include_file(&entries, true);
assert!(
Expand All @@ -996,9 +1078,18 @@ mod tests {
// Two files in the same depth-2 package: verifies the relative flag
// propagates through recursive emit() calls and both files land in
// the same innermost mod.
use buffa_codegen::GeneratedFileKind;
let entries = vec![
("a.b.one.rs".to_string(), "a.b".to_string()),
("a.b.two.rs".to_string(), "a.b".to_string()),
(
"a.b.one.rs".to_string(),
"a.b".to_string(),
GeneratedFileKind::Owned,
),
(
"a.b.two.rs".to_string(),
"a.b".to_string(),
GeneratedFileKind::Owned,
),
];
let out = generate_include_file(&entries, true);
// Both includes should appear once, at the same depth-2 indent,
Expand Down
28 changes: 25 additions & 3 deletions buffa-codegen/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,17 @@ impl<'a> CodeGenContext<'a> {
self.type_map.get(proto_fqn).map(|s| s.as_str())
}

/// Look up the proto package of a fully-qualified protobuf type name.
///
/// `proto_fqn` uses dotted form without the leading dot
/// (e.g. `"google.protobuf.Timestamp"`). Returns `None` for types
/// not present in the compilation set (extern types fall through to
/// the fallback path at call sites — view-path resolution uses this
/// to distinguish top-level vs nested targets).
pub(crate) fn package_of(&self, proto_fqn: &str) -> Option<&str> {
self.package_of.get(proto_fqn).map(|s| s.as_str())
}

/// Look up the source comment for a protobuf element by FQN.
///
/// `fqn` uses the same dotted form as `proto_fqn` throughout codegen
Expand Down Expand Up @@ -361,6 +372,13 @@ pub(crate) struct MessageScope<'a> {
/// sits inside. Controls the count of `super::` prefixes in type
/// references via [`CodeGenContext::rust_type_relative`].
pub nesting: usize,
/// `true` when the scope is emitting code into the top-level `view::`
/// module (the view tree at package scope). `false` for owned-type
/// scopes AND for nested-message views, which continue to live inside
/// the owner's message sub-module as `FooView<'a>` (the pre-namespace
/// layout). Threaded so view-path helpers can choose between the
/// `super::view::FooView<'a>` and legacy `FooView<'a>` rewrites.
pub in_view_tree: bool,
}

impl<'a> MessageScope<'a> {
Expand All @@ -372,14 +390,18 @@ impl<'a> MessageScope<'a> {
proto_fqn,
features,
nesting: self.nesting + 1,
in_view_tree: self.in_view_tree,
}
}

/// Return a copy of this scope with the nesting depth incremented by 1.
///
/// Use this when generating code that will live inside the message's own
/// `pub mod` (e.g. oneof view enums) without changing the identity
/// (`proto_fqn`, `features`).
/// Previously used for oneof-view-enum emission (one module deeper
/// than the view struct). The `oneofs::` tree lift takes emission
/// two levels deeper (into `view::oneofs::<owner>`) rather than
/// one, so view.rs now constructs the scope explicitly; this
/// helper remains as a general utility for future consumers.
#[allow(dead_code)]
pub fn deeper(&self) -> MessageScope<'a> {
MessageScope {
nesting: self.nesting + 1,
Expand Down
17 changes: 11 additions & 6 deletions buffa-codegen/src/impl_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,11 @@ pub fn generate_message_impl(
.map(|f| repeated_merge_arm(ctx, f, proto_fqn, features, preserve_unknown_fields))
.collect::<Result<Vec<_>, _>>()?;

// Collect oneof compute/write/merge tokens.
let mod_ident = crate::message::make_field_ident(&crate::oneof::to_snake_case(rust_name));
// Collect oneof compute/write/merge tokens. The oneof enum lives
// in the parallel `oneofs::` tree; `oneofs_prefix` is the token
// stream path from the current emission scope (owned message
// impl) to the oneofs sub-module for this message.
let oneofs_prefix = crate::message::oneofs_path_prefix(current_package, proto_fqn, nesting);
let mut oneof_compute_stmts: Vec<TokenStream> = Vec::new();
let mut oneof_write_stmts: Vec<TokenStream> = Vec::new();
let mut oneof_merge_arms: Vec<TokenStream> = Vec::new();
Expand All @@ -326,7 +329,7 @@ pub fn generate_message_impl(
enum_ident,
oneof_name,
fields,
&mod_ident,
&oneofs_prefix,
proto_fqn,
features,
preserve_unknown_fields,
Expand Down Expand Up @@ -2119,14 +2122,16 @@ fn generate_oneof_impls(
enum_ident: &proc_macro2::Ident,
oneof_name: &str,
fields: &[&FieldDescriptorProto],
mod_ident: &proc_macro2::Ident,
oneofs_prefix: &TokenStream,
proto_fqn: &str,
features: &ResolvedFeatures,
preserve_unknown_fields: bool,
) -> Result<(TokenStream, TokenStream, Vec<TokenStream>), CodeGenError> {
let field_ident = make_field_ident(oneof_name);
// Module-qualified path: the oneof enum lives in the message's module.
let qualified_enum: TokenStream = quote! { #mod_ident::#enum_ident };
// Module-qualified path: the oneof enum lives in the parallel
// `oneofs::` tree at `<oneofs_prefix>::<Kind>`. `oneofs_prefix`
// already ends with `::`.
let qualified_enum: TokenStream = quote! { #oneofs_prefix #enum_ident };

let mut size_arms: Vec<TokenStream> = Vec::new();
let mut write_arms: Vec<TokenStream> = Vec::new();
Expand Down
Loading
Loading