diff --git a/buffa-codegen/src/lib.rs b/buffa-codegen/src/lib.rs index 710bc58..c7e4bd3 100644 --- a/buffa-codegen/src/lib.rs +++ b/buffa-codegen/src/lib.rs @@ -81,6 +81,10 @@ pub fn allow_lints_attr() -> TokenStream { /// per-proto content kinds are reached transitively via `include!` from /// the stitcher. Write all files to disk; build a module tree from only /// the `PackageMod` ones. +/// +/// With [`CodeGenConfig::file_per_package`] set, the per-proto content +/// kinds are not emitted at all — the single `.rs` (still +/// kind `PackageMod`) inlines what the stitcher would `include!`. #[derive(Debug)] pub struct GeneratedFile { /// The output file path (e.g., `"my.pkg.foo.rs"` or `"my.pkg.mod.rs"`). @@ -100,7 +104,8 @@ pub struct GeneratedFile { /// /// Build integrations only need to wire up [`PackageMod`](Self::PackageMod) /// entries — the per-proto content kinds are reached via `include!` from -/// the stitcher and need only be written to disk alongside it. +/// the stitcher and need only be written to disk alongside it. Under +/// [`CodeGenConfig::file_per_package`] only `PackageMod` is emitted. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GeneratedFileKind { /// Owned message structs and enums (`.rs`). @@ -207,6 +212,21 @@ pub struct CodeGenConfig { /// not). The per-message `__*_JSON_ANY` / `__*_TEXT_ANY` consts are /// still emitted; only the aggregating fn is suppressed. pub emit_register_fn: bool, + /// Emit one `.rs` per proto package instead of the + /// per-proto-file content set plus `.mod.rs` stitcher. + /// + /// The single file inlines what the stitcher would otherwise `include!`, + /// producing the same `__buffa::{view,oneof,ext,...}` module structure. + /// Intended for Buf Schema Registry generated SDKs, whose `lib.rs` + /// synthesis builds the module tree from `.rs` filenames. + /// + /// Under `strategy: directory` this only sees one directory's files per + /// invocation, so the input module must be `PACKAGE_DIRECTORY_MATCH`-clean + /// (one package per directory) for the output to be complete. BSR-hosted + /// modules satisfy this by lint default. If a package spans multiple + /// directories, separate invocations each emit their own `.rs` and + /// the last write wins — silent partial output, not a codegen error. + pub file_per_package: bool, /// Custom attributes to inject on generated types (messages and enums). /// /// Each entry is `(proto_path, attribute)`. The `proto_path` is matched @@ -249,6 +269,7 @@ impl Default for CodeGenConfig { allow_message_set: false, generate_text: false, emit_register_fn: true, + file_per_package: false, type_attributes: Vec::new(), field_attributes: Vec::new(), message_attributes: Vec::new(), @@ -650,8 +671,57 @@ fn generate_proto_content( }) } +/// Per-section token streams for one package, ready for the stitcher. +/// +/// In per-file mode each section holds `include!("...rs")` calls; in +/// `file_per_package` mode each holds the actual generated items. +#[derive(Default)] +struct PackageSections { + owned: Vec, + view: Vec, + oneof: Vec, + view_oneof: Vec, + ext: Vec, +} + +impl PackageSections { + /// Build sections of `include!` calls referencing per-file content. + /// + /// Paths are bare-sibling (no `OUT_DIR` prefix) so the same stitcher + /// works for both `OUT_DIR` builds (where the consumer's + /// `include_proto!` already prepended `OUT_DIR`) and checked-in code. + fn from_stems(stems: &[String]) -> Self { + let includes = |suffix: &str| -> Vec { + stems + .iter() + .map(|stem| { + let path = format!("{stem}{suffix}.rs"); + quote! { include!(#path); } + }) + .collect() + }; + Self { + owned: includes(""), + view: includes(".__view"), + oneof: includes(".__oneof"), + view_oneof: includes(".__view_oneof"), + ext: includes(".__ext"), + } + } + + /// Append one proto file's generated items in-line. + fn push_inline(&mut self, pc: ProtoContent) { + self.owned.push(pc.owned); + self.view.push(pc.view); + self.oneof.push(pc.oneof); + self.view_oneof.push(pc.view_oneof); + self.ext.push(pc.ext); + } +} + /// Generate all output files for one proto package: five content files per -/// `.proto` plus one `.mod.rs` stitcher. +/// `.proto` plus one `.mod.rs` stitcher, or a single `.rs` when +/// [`CodeGenConfig::file_per_package`] is set. fn generate_package( ctx: &context::CodeGenContext, current_package: &str, @@ -662,74 +732,79 @@ fn generate_package( // `__buffa::register_types` (one level deep), so each path gets a // single `super::` prefix when emitted into the fn body. let mut reg = message::RegistryPaths::default(); - let mut stems: Vec = Vec::new(); - - for file in files { - let pc = generate_proto_content(ctx, current_package, file, &mut reg)?; - let source = file.name.as_deref().unwrap_or(""); - let push = |out: &mut Vec, - suffix: &str, - kind: GeneratedFileKind, - tokens: TokenStream| - -> Result<(), CodeGenError> { - out.push(GeneratedFile { - name: format!("{}{suffix}.rs", pc.stem), - package: current_package.to_string(), - kind, - content: format_tokens(tokens, source)?, - }); - Ok(()) - }; - push(out, "", GeneratedFileKind::Owned, pc.owned)?; - push(out, ".__view", GeneratedFileKind::View, pc.view)?; - push(out, ".__oneof", GeneratedFileKind::Oneof, pc.oneof)?; - push( - out, - ".__view_oneof", - GeneratedFileKind::ViewOneof, - pc.view_oneof, - )?; - push(out, ".__ext", GeneratedFileKind::Ext, pc.ext)?; - stems.push(pc.stem); - } + + let sections = if ctx.config.file_per_package { + let mut sections = PackageSections::default(); + for file in files { + sections.push_inline(generate_proto_content( + ctx, + current_package, + file, + &mut reg, + )?); + } + sections + } else { + let mut stems: Vec = Vec::new(); + for file in files { + let pc = generate_proto_content(ctx, current_package, file, &mut reg)?; + let source = file.name.as_deref().unwrap_or(""); + let push = |out: &mut Vec, + suffix: &str, + kind: GeneratedFileKind, + tokens: TokenStream| + -> Result<(), CodeGenError> { + out.push(GeneratedFile { + name: format!("{}{suffix}.rs", pc.stem), + package: current_package.to_string(), + kind, + content: format_tokens(tokens, source)?, + }); + Ok(()) + }; + push(out, "", GeneratedFileKind::Owned, pc.owned)?; + push(out, ".__view", GeneratedFileKind::View, pc.view)?; + push(out, ".__oneof", GeneratedFileKind::Oneof, pc.oneof)?; + push( + out, + ".__view_oneof", + GeneratedFileKind::ViewOneof, + pc.view_oneof, + )?; + push(out, ".__ext", GeneratedFileKind::Ext, pc.ext)?; + stems.push(pc.stem); + } + PackageSections::from_stems(&stems) + }; out.push(GeneratedFile { - name: package_to_mod_filename(current_package), + name: if ctx.config.file_per_package { + package_to_filename(current_package) + } else { + package_to_mod_filename(current_package) + }, package: current_package.to_string(), kind: GeneratedFileKind::PackageMod, - content: generate_package_mod(ctx, &stems, ®)?, + content: generate_package_mod(ctx, §ions, ®)?, }); Ok(()) } -/// Render the per-package `.mod.rs` stitcher. -/// -/// `include!` paths are bare-sibling (no `OUT_DIR` prefix) so the same -/// stitcher works for both `OUT_DIR` builds (where the consumer's -/// `include_proto!` already prepended `OUT_DIR`) and checked-in code. +/// Render the per-package stitcher: owned items at root plus the +/// `__buffa::{view,oneof,ext,...}` module wrappers. fn generate_package_mod( ctx: &context::CodeGenContext, - stems: &[String], + sections: &PackageSections, reg: &message::RegistryPaths, ) -> Result { use crate::idents::make_field_ident; - let includes = |suffix: &str| -> Vec { - stems - .iter() - .map(|stem| { - let path = format!("{stem}{suffix}.rs"); - quote! { include!(#path); } - }) - .collect() - }; - - let owned = includes(""); - let view = includes(".__view"); - let view_oneof = includes(".__view_oneof"); - let oneof = includes(".__oneof"); - let ext = includes(".__ext"); + let owned = §ions.owned; + let view = §ions.view; + let view_oneof = §ions.view_oneof; + let oneof = §ions.oneof; + let ext = §ions.ext; let view_mod = if ctx.config.generate_views { quote! { @@ -823,6 +898,21 @@ pub fn package_to_mod_filename(package: &str) -> String { } } +/// Convert a proto package name to its [`file_per_package`] output filename. +/// +/// e.g., `"google.protobuf"` → `"google.protobuf.rs"`. The unnamed +/// package uses [`SENTINEL_MOD`](context::SENTINEL_MOD) — same +/// collision-avoidance as [`package_to_mod_filename`]. +/// +/// [`file_per_package`]: CodeGenConfig::file_per_package +pub fn package_to_filename(package: &str) -> String { + if package.is_empty() { + format!("{}.rs", context::SENTINEL_MOD) + } else { + format!("{package}.rs") + } +} + /// Convert a `.proto` file path to its content-file stem. /// /// e.g., `"google/protobuf/timestamp.proto"` → `"google.protobuf.timestamp"`. diff --git a/buffa-codegen/src/tests/generation.rs b/buffa-codegen/src/tests/generation.rs index 83db867..f522f73 100644 --- a/buffa-codegen/src/tests/generation.rs +++ b/buffa-codegen/src/tests/generation.rs @@ -120,6 +120,223 @@ fn test_multi_file_same_package_merged() { assert!(stitcher.content.contains(r#"include!("b.__view.rs");"#)); } +#[test] +fn test_package_to_filename() { + assert_eq!(package_to_filename("google.protobuf"), "google.protobuf.rs"); + assert_eq!(package_to_filename("foo"), "foo.rs"); + assert_eq!(package_to_filename(""), "__buffa.rs"); +} + +/// Two `.proto` files in `shared.pkg`. `a.proto` has a message with an +/// explicit oneof and a nested type so the `__buffa::{oneof,view::oneof}` +/// modules and per-message child modules are non-empty — needed for the +/// module-structure parity assertions below. +fn shared_pkg_fixture() -> ([FileDescriptorProto; 2], [String; 2]) { + let mut a = proto3_file("a.proto"); + a.package = Some("shared.pkg".to_string()); + a.message_type.push(DescriptorProto { + name: Some("A".to_string()), + field: vec![ + FieldDescriptorProto { + name: Some("x".to_string()), + number: Some(1), + label: Some(Label::LABEL_OPTIONAL), + r#type: Some(Type::TYPE_INT32), + oneof_index: Some(0), + ..Default::default() + }, + FieldDescriptorProto { + name: Some("y".to_string()), + number: Some(2), + label: Some(Label::LABEL_OPTIONAL), + r#type: Some(Type::TYPE_STRING), + oneof_index: Some(0), + ..Default::default() + }, + ], + oneof_decl: vec![OneofDescriptorProto { + name: Some("kind".to_string()), + ..Default::default() + }], + nested_type: vec![DescriptorProto { + name: Some("Inner".to_string()), + ..Default::default() + }], + ..Default::default() + }); + let mut b = proto3_file("b.proto"); + b.package = Some("shared.pkg".to_string()); + b.message_type.push(DescriptorProto { + name: Some("B".to_string()), + ..Default::default() + }); + ([a, b], ["a.proto".to_string(), "b.proto".to_string()]) +} + +#[test] +fn test_file_per_package_multi_file() { + let (descs, names) = shared_pkg_fixture(); + let config = CodeGenConfig { + file_per_package: true, + ..Default::default() + }; + let files = generate(&descs, &names, &config).expect("file_per_package should generate"); + // Exactly one output file for the package — no per-proto content files. + assert_eq!(files.len(), 1, "expected single per-package file"); + let pkg = &files[0]; + assert_eq!(pkg.name, "shared.pkg.rs"); + assert_eq!(pkg.kind, GeneratedFileKind::PackageMod); + // Both messages inlined; no `include!` calls. + assert!(pkg.content.contains("pub struct A")); + assert!(pkg.content.contains("pub struct B")); + assert!( + !pkg.content.contains("include!"), + "per-package file must inline content, not include! per-file outputs" + ); + // Same `__buffa` module wrappers as the per-file stitcher. + assert_eq!(pkg.content.matches("pub mod __buffa {").count(), 1); + assert!(pkg.content.contains("pub mod view {")); + assert!(pkg.content.contains("pub mod oneof {")); + assert!(pkg.content.contains("pub mod ext {")); +} + +#[test] +fn test_file_per_package_module_structure_matches_stitcher() { + // The single-file output's module structure must be identical to what + // the per-file stitcher produces after `include!` resolution, so + // consumers see the same API regardless of mode. + let (descs, names) = shared_pkg_fixture(); + let per_file = generate(&descs, &names, &CodeGenConfig::default()).unwrap(); + let stitcher = per_file + .iter() + .find(|f| f.kind == GeneratedFileKind::PackageMod) + .unwrap(); + let per_package = generate( + &descs, + &names, + &CodeGenConfig { + file_per_package: true, + ..Default::default() + }, + ) + .unwrap(); + let pkg = &per_package[0]; + + // Splice each `include!("X.rs");` in the stitcher with the matching + // content file's body — modeling what rustc sees after expansion. + // Drop the `// @generated …` / `// source: …` header so spliced + // content doesn't introduce comment lines mid-module. + fn strip_header(s: &str) -> &str { + s.find("\n\n").map_or(s, |i| &s[i + 2..]) + } + let mut spliced = stitcher.content.clone(); + for f in per_file + .iter() + .filter(|f| f.kind != GeneratedFileKind::PackageMod) + { + let needle = format!(r#"include!("{}");"#, f.name); + spliced = spliced.replace(&needle, strip_header(&f.content)); + } + assert!( + !spliced.contains("include!"), + "splice missed an include: {spliced}" + ); + + // Compare the depth-aware sequence of `pub mod` declarations. Depth + // is brace-tracked (not indent-tracked) so the spliced content — + // which is not re-indented — is measured correctly. + let mod_decls = |s: &str| -> Vec<(usize, String)> { + let mut depth = 0usize; + let mut out = Vec::new(); + for l in s.lines() { + let trimmed = l.trim_start(); + if let Some(rest) = trimmed.strip_prefix("pub mod ") { + out.push((depth, rest.trim_end_matches(" {").to_string())); + } + depth += l.matches('{').count(); + depth = depth.saturating_sub(l.matches('}').count()); + } + out + }; + let spliced_mods = mod_decls(&spliced); + let pkg_mods = mod_decls(&pkg.content); + // Non-vacuous: fixture has a oneof and a nested type, so the + // `__buffa::oneof::a`, `__buffa::view::oneof::a`, and per-message + // `a` child modules are present in addition to the wrapper modules. + assert!( + spliced_mods.len() > 5, + "fixture should produce >5 pub mod decls, got {}: {spliced_mods:?}", + spliced_mods.len() + ); + assert_eq!(spliced_mods, pkg_mods); +} + +#[test] +fn test_file_per_package_register_types_with_text() { + // `register_types` paths are package-root-relative (`super::…`) and + // must resolve identically when content is inlined vs `include!`d. + let (descs, names) = shared_pkg_fixture(); + let config = CodeGenConfig { + file_per_package: true, + generate_text: true, + ..Default::default() + }; + let files = generate(&descs, &names, &config).unwrap(); + assert_eq!(files.len(), 1); + let content = &files[0].content; + assert!( + content.contains("pub fn register_types("), + "register_types fn missing: {content}" + ); + assert!( + content.contains("reg.register_text_any(super::__A_TEXT_ANY)"), + "A text-any path: {content}" + ); + assert!( + content.contains("reg.register_text_any(super::a::__INNER_TEXT_ANY)"), + "nested Inner text-any path: {content}" + ); + assert!( + content.contains("reg.register_text_any(super::__B_TEXT_ANY)"), + "B text-any path: {content}" + ); +} + +#[test] +fn test_file_per_package_unnamed_package() { + let file = proto3_file("noname.proto"); + let config = CodeGenConfig { + file_per_package: true, + ..Default::default() + }; + let files = generate(&[file], &["noname.proto".to_string()], &config) + .expect("unnamed package should generate"); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "__buffa.rs"); +} + +#[test] +fn test_file_per_package_multiple_packages() { + // Each package gets exactly one file. + let mut a = proto3_file("a.proto"); + a.package = Some("alpha".to_string()); + let mut b = proto3_file("b.proto"); + b.package = Some("beta".to_string()); + let config = CodeGenConfig { + file_per_package: true, + ..Default::default() + }; + let files = generate( + &[a, b], + &["a.proto".to_string(), "b.proto".to_string()], + &config, + ) + .expect("multi-package should generate"); + assert_eq!(files.len(), 2); + let names: Vec<_> = files.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, &["alpha.rs", "beta.rs"]); +} + #[test] fn test_child_package_named_view_no_collision() { // Regression: under the pre-sentinel design, `package foo.view` diff --git a/docs/guide.md b/docs/guide.md index 4a263eb..9954766 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -362,6 +362,7 @@ Plugin options (passed via `opt:`): | `unknown_fields=false` | Disable unknown field preservation | | `arbitrary=true` | Emit `#[derive(arbitrary::Arbitrary)]` for fuzzing | | `extern_path=.pkg=::rust` | Map a proto package to an external Rust path | +| `file_per_package=true` | Emit one `.rs` per package instead of per-proto-file content + stitcher; intended for BSR generated SDKs. Under `strategy: directory`, requires the input module to be `PACKAGE_DIRECTORY_MATCH`-clean | **Remote plugin (planned):** Once published to the Buf Schema Registry, the plugin will be available as a remote plugin without requiring a local install: diff --git a/protoc-gen-buffa/src/main.rs b/protoc-gen-buffa/src/main.rs index 1398900..2a32fc9 100644 --- a/protoc-gen-buffa/src/main.rs +++ b/protoc-gen-buffa/src/main.rs @@ -136,6 +136,7 @@ fn parse_config(params: &str) -> Result { codegen.strict_utf8_mapping = value.trim() == "true" } "register_types" => codegen.emit_register_fn = value.trim() != "false", + "file_per_package" => codegen.file_per_package = value.trim() == "true", "extern_path" => { // value is "=" if let Some((proto, rust)) = value.split_once('=') { @@ -216,6 +217,18 @@ mod tests { assert!(config.codegen.preserve_unknown_fields); } + #[test] + fn file_per_package_true() { + let config = parse_config("file_per_package=true").unwrap(); + assert!(config.codegen.file_per_package); + } + + #[test] + fn file_per_package_default_is_false() { + let config = parse_config("").unwrap(); + assert!(!config.codegen.file_per_package); + } + #[test] fn extern_path_with_leading_dot() { let config = parse_config("extern_path=.my.common=::common_protos").unwrap();