diff --git a/.changes/unreleased/added-20260829-201650.yaml b/.changes/unreleased/added-20260829-201650.yaml new file mode 100644 index 00000000..223af467 --- /dev/null +++ b/.changes/unreleased/added-20260829-201650.yaml @@ -0,0 +1,4 @@ +kind: Added +body: |- + **`buffa-build`: `Config::exclude_package` knob** (#383, closes #293). `Config::exclude_package("buf.validate")` (or `.exclude_package(".buf.validate")`) drops all `.proto` files in that package — and any sub-packages — from code generation. Multiple calls accumulate. The same knob is exposed as `exclude_package=` in the `protoc-gen-buffa` plugin option string and as `CodeGenConfig::exclude_packages` for direct codegen users. An entry that matches no package in the input now raises `CodeGenWarning::ExcludePackageMatchedNothing` (a `cargo:warning` from `buffa-build`). +time: 2026-08-29T20:16:50.447198+05:45 diff --git a/buffa-build/src/lib.rs b/buffa-build/src/lib.rs index 85561c48..37f9b5b7 100644 --- a/buffa-build/src/lib.rs +++ b/buffa-build/src/lib.rs @@ -880,6 +880,54 @@ impl Config { self } + /// Exclude a proto package from code generation. + /// + /// `package` is the proto package to exclude (e.g. `"buf.validate"`, + /// `".gnostic.openapi.v3"`). A leading dot is optional and stripped + /// automatically. The package and all of its sub-packages are excluded: + /// `"buf.validate"` drops both `buf.validate` and `buf.validate.priv`. + /// + /// Use this when proto files from option-only packages (e.g. + /// `buf/validate/validate.proto`, gnostic annotations) end up in the + /// generate set through directory globbing, but you do not want Rust types + /// generated for those packages. Their descriptors remain available for + /// cross-package type resolution; only code generation is skipped. + /// + /// **Warning**: if any kept file references an excluded package as a field + /// type, the generated code will contain dangling `super::…::Type` paths + /// that fail to compile; the build emits a `cargo:warning` naming the + /// file, message, and field before that happens. Pair `exclude_package` with + /// [`extern_path`](Self::extern_path) to map the excluded types to an + /// external crate, or do not list those `.proto` files in + /// [`files`](Self::files). + /// + /// This method can be called multiple times to exclude multiple packages. + /// + /// # Validation + /// + /// [`compile`](Self::compile) returns an error, before running `protoc`, if + /// `package` is empty or contains invalid components (empty segments, + /// consecutive dots). A single leading dot is allowed and stripped. + /// + /// # Example + /// + /// ```rust,ignore + /// buffa_build::Config::new() + /// .exclude_package("buf.validate") + /// .exclude_package(".gnostic.openapi.v3") + /// .files(&["proto/my_service.proto"]) + /// .includes(&["proto/", "vendor/"]) + /// .compile() + /// .unwrap(); + /// ``` + #[must_use] + pub fn exclude_package(mut self, package: impl Into) -> Self { + // Stored raw. `compile()` rejects malformed entries up front, and + // `generate_with_diagnostics` strips the optional leading dot. + self.codegen_config.exclude_packages.push(package.into()); + self + } + /// Configure `bytes` fields to use `bytes::Bytes` instead of `Vec`. /// /// Each path is a fully-qualified proto path prefix. Use `"."` to apply @@ -1715,6 +1763,15 @@ impl Config { /// - code generation fails (e.g. unsupported proto feature) /// - the output directory cannot be created or written to pub fn compile(self) -> Result<(), Box> { + // Reject malformed `exclude_package` entries before protoc runs; the + // codegen normalizes them again, but a typo should not cost a protoc + // invocation to surface. + for entry in &self.codegen_config.exclude_packages { + if let Err(e) = buffa_codegen::normalize_exclude_package(entry) { + return Err(format!("exclude_package {entry:?}: {e}").into()); + } + } + // Validate the shared-pool prerequisites before doing any work, and // check reflection first so the error names the actually-missing // prerequisite rather than a downstream one. `generate_reflection` is @@ -2763,4 +2820,110 @@ mod tests { .collect(); assert_eq!(paths, vec!["#[derive(A)]", "#[derive(B)]", "#[derive(C)]"]); } + + #[test] + fn exclude_package_stores_raw_value_for_deferred_normalization() { + // Normalization (leading-dot strip, validation) happens in + // generate_with_diagnostics; the builder stores the raw string so + // compile() can surface errors with the original user-supplied value. + let cfg = Config::new() + .exclude_package(".buf.validate") + .exclude_package("gnostic"); + assert_eq!( + cfg.codegen_config.exclude_packages, + vec![".buf.validate", "gnostic"], + ); + } + + #[test] + fn exclude_package_accumulates_in_order() { + let cfg = Config::new() + .exclude_package("a.b") + .exclude_package("c.d") + .exclude_package("e.f"); + assert_eq!( + cfg.codegen_config.exclude_packages, + vec!["a.b", "c.d", "e.f"] + ); + } + + #[test] + fn exclude_package_rejects_malformed_entries_before_reading_input() { + // The descriptor set path does not exist, so an error can only come + // from the validation that runs ahead of any input processing. + let err = Config::new() + .descriptor_set("/nonexistent/set.binpb") + .files(&["foo/v1/thing.proto"]) + .out_dir("/nonexistent/out") + .exclude_package("buf..validate") + .compile() + .expect_err("malformed exclude_package must fail compile()"); + let msg = err.to_string(); + assert!(msg.contains("exclude_package \"buf..validate\""), "{msg}"); + assert!(msg.contains("empty components"), "{msg}"); + } + + #[test] + fn exclude_package_drops_the_package_from_the_output() { + use buffa_codegen::generated::descriptor::field_descriptor_proto::{Label, Type}; + use buffa_codegen::generated::descriptor::{ + DescriptorProto, FieldDescriptorProto, FileDescriptorProto, + }; + + // Two single-message packages, both listed for generation; the + // excluded one must produce no module, no include entry, and no + // stitcher. + let file = |name: &str, package: &str, message: &str| FileDescriptorProto { + name: Some(name.into()), + package: Some(package.into()), + syntax: Some("proto3".into()), + message_type: vec![DescriptorProto { + name: Some(message.into()), + field: vec![FieldDescriptorProto { + name: Some("id".into()), + number: Some(1), + label: Some(Label::LABEL_OPTIONAL), + r#type: Some(Type::TYPE_INT32), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + let files = [ + file("foo/v1/thing.proto", "foo.v1", "Thing"), + file("buf/validate/validate.proto", "buf.validate", "Rule"), + ]; + let fds_bytes = buffa_codegen::encode_descriptor_set(&files, &[]); + + let dir = tempfile::tempdir().unwrap(); + let fds_path = dir.path().join("set.binpb"); + std::fs::write(&fds_path, &fds_bytes).unwrap(); + let out = dir.path().join("gen"); + + Config::new() + .descriptor_set(&fds_path) + .files(&["foo/v1/thing.proto", "buf/validate/validate.proto"]) + .out_dir(&out) + .include_file("gen_mod.rs") + .exclude_package(".buf.validate") + .compile() + .expect("compile with an excluded package should succeed"); + + let names: Vec = std::fs::read_dir(&out) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + names.iter().any(|n| n.starts_with("foo.v1.")), + "kept package must be generated: {names:?}" + ); + assert!( + !names.iter().any(|n| n.contains("buf.validate")), + "excluded package must produce no files: {names:?}" + ); + let include = std::fs::read_to_string(out.join("gen_mod.rs")).unwrap(); + assert!(include.contains("foo"), "{include}"); + assert!(!include.contains("validate"), "{include}"); + } } diff --git a/buffa-codegen/src/lib.rs b/buffa-codegen/src/lib.rs index 1b61d9b2..14d97a9b 100644 --- a/buffa-codegen/src/lib.rs +++ b/buffa-codegen/src/lib.rs @@ -1648,6 +1648,32 @@ pub struct CodeGenConfig { /// [`CodeGenError::InvalidTypeNamePrefix`] otherwise. Defaults to `""` /// (no prefix). pub type_name_prefix: String, + /// Proto packages to exclude from code generation (default: empty). + /// + /// Each entry is a normalized proto package name without a leading dot + /// (e.g. `"buf.validate"`, `"gnostic.openapi.v3"`). A package matches + /// if it equals an entry exactly or starts with `"."` — so + /// `"buf.validate"` excludes both `buf.validate` and `buf.validate.priv`. + /// + /// Use this when `.proto` files from option-only packages (e.g. + /// `buf/validate/validate.proto`, gnostic annotations) end up in the + /// generate set via `include_imports` or directory globbing, but you do + /// not want Rust types for those packages. Their descriptors remain in + /// the compilation for type resolution; only code generation is skipped. + /// + /// **If you exclude a package that other kept files reference as a field + /// type**, you must also add an [`extern_path`](Self::extern_paths) mapping + /// for it, or the generated code will contain dangling `super::…::Type` + /// paths that fail to compile ([`CodeGenWarning::ExcludedPackageFieldRef`] + /// names the offending field first). + /// + /// Entries are proto package names with no empty segments (e.g. + /// `"buf.validate"`, `"gnostic.openapi.v3"`); a leading dot is accepted + /// and stripped at generation time. [`generate_with_diagnostics`] rejects + /// anything else, and warns with + /// [`CodeGenWarning::ExcludePackageMatchedNothing`] for an entry that + /// matches no package in the input. + pub exclude_packages: Vec, } impl Default for CodeGenConfig { @@ -1687,6 +1713,7 @@ impl Default for CodeGenConfig { idiomatic_field_names: false, feature_gate_names: FeatureGateNames::default(), type_name_prefix: String::new(), + exclude_packages: Vec::new(), } } } @@ -1946,9 +1973,9 @@ pub enum CodeGenWarning { /// configuration gap is. /// /// Fix by either: - /// - Including the referenced package in the generation request (remove the - /// `exclude_package` plugin directive, or add the missing `.proto` files to - /// the generate set), or + /// - Including the referenced package in the generation request (drop the + /// `exclude_package` plugin directive or the `.exclude_package(…)` call, + /// or add the missing `.proto` files to the generate set), or /// - Adding an `extern_path` mapping for the package pointing to the crate /// that provides its generated types. /// @@ -1978,6 +2005,21 @@ pub enum CodeGenWarning { /// e.g. `".buf.validate.FieldConstraints"`. type_fqn: String, }, + /// An [`exclude_packages`](CodeGenConfig::exclude_packages) entry matched + /// no proto package in the descriptor set. Usually a typo in the package + /// name or a stale entry left after a proto reorganization — the entry is + /// accepted but changes nothing. + /// + /// Check the spelling against the `package` declarations in your `.proto` + /// files. The match is exact or prefix-on-component-boundary, so + /// `"buf.validate"` covers `buf.validate` and `buf.validate.priv` but not + /// `buf.validatex`. + #[non_exhaustive] + ExcludePackageMatchedNothing { + /// The normalized package entry that matched nothing + /// (leading dot already stripped). + package: String, + }, /// A [`feature_overrides`](CodeGenConfig::feature_overrides) rule matched /// nothing the override targets in the compiled descriptor set, so it /// changed nothing. Usually a typo, a missing nested-message segment, or @@ -2080,7 +2122,17 @@ impl core::fmt::Display for CodeGenWarning { plugin: `extern_path={type_fqn}=::your_crate::Type` or \ `extern_path=.{ref_package}=::your_crate`), or include the \ defining .proto file in the generate set \ - (buffa-build: `.files(&[…])`; plugin: drop `exclude_package=`)" + (buffa-build: `.files(&[…])` or drop `.exclude_package(…)`; \ + plugin: drop `exclude_package=`)" + ) + } + Self::ExcludePackageMatchedNothing { package } => { + write!( + f, + "exclude_package entry \"{package}\" matched no proto package in the \ + descriptor set — check for a typo or a stale entry \ + (exact match or dotted-prefix: \"buf.validate\" covers \ + buf.validate and buf.validate.priv, not buf.validatex)" ) } Self::FeatureOverrideMatchedNothing { @@ -2622,6 +2674,50 @@ pub fn generate_with_diagnostics( config.validate_type_name_prefix()?; + // Normalize exclude_packages entries (optional leading dot stripped). The + // plugin normalizes before it gets here and buffa-build validates in + // `compile()`, so this is the one place a direct `CodeGenConfig` caller's + // entries are checked; the message is the normalizer's own. + let normalized_excludes: Vec = config + .exclude_packages + .iter() + .map(|entry| { + normalize_exclude_package(entry) + .map_err(|e| CodeGenError::Other(format!("exclude_package {entry:?}: {e}"))) + }) + .collect::>()?; + // Drop files whose proto package is listed in `config.exclude_packages` + // BEFORE building the codegen context, so that context decisions that key + // on `files_to_generate` membership (e.g. WKT auto-extern-path injection, + // descriptor.proto suppression) see the filtered set. Their descriptors + // stay in `file_descriptors` for type resolution; only code generation is + // skipped. A file with no matching descriptor is kept — the FileNotFound + // error below is more actionable than silently dropping it here. + let effective_files_to_generate: std::borrow::Cow<'_, [String]> = + if normalized_excludes.is_empty() { + std::borrow::Cow::Borrowed(files_to_generate) + } else { + std::borrow::Cow::Owned( + files_to_generate + .iter() + .filter(|name| { + match file_descriptors + .iter() + .find(|fd| fd.name.as_deref() == Some(name.as_str())) + { + Some(fd) => !package_is_excluded( + fd.package.as_deref().unwrap_or(""), + &normalized_excludes, + ), + None => true, + } + }) + .cloned() + .collect(), + ) + }; + let files_to_generate: &[String] = &effective_files_to_generate; + // Feature overrides are applied by mutating the descriptor set up front, // so every downstream consumer — feature resolution, all generation // paths, and the embedded reflection descriptor pool — reads the same @@ -2652,6 +2748,25 @@ pub fn generate_with_diagnostics( ctx.warn(CodeGenWarning::LazyViewsRequireViews); } + // Warn about exclude_packages entries that matched no package in the full + // descriptor set — likely a typo or stale entry. Checked against + // file_descriptors (not just files_to_generate) so plugin users without + // include_imports don't get spurious warnings for packages they never + // compiled in the first place. + for package in &normalized_excludes { + let matched = file_descriptors.iter().any(|fd| { + package_is_excluded( + fd.package.as_deref().unwrap_or(""), + std::slice::from_ref(package), + ) + }); + if !matched { + ctx.warn(CodeGenWarning::ExcludePackageMatchedNothing { + package: package.clone(), + }); + } + } + // Group requested files by package. BTreeMap → deterministic output order. let mut by_package: std::collections::BTreeMap> = std::collections::BTreeMap::new(); @@ -3892,12 +4007,12 @@ pub fn package_to_mod_filename(package: &str) -> String { /// paths without a leading dot (`buf.validate`, not `.buf.validate`); an /// empty entry matches only the unnamed package. /// -/// Both `protoc-gen-buffa` (which filters `file_to_generate` before codegen) -/// and `protoc-gen-buffa-packaging` (which filters the packages it stitches -/// into `mod.rs`) route their exclusion through this one predicate, so the -/// two plugins are guaranteed to drop exactly the same set — the invariant -/// the packaging plugin's "Matching a codegen plugin's output set" note -/// depends on. +/// `generate_with_diagnostics` (which filters `files_to_generate` before +/// building the codegen context) and `protoc-gen-buffa-packaging` (which +/// filters the packages it stitches into `mod.rs`) route their exclusion +/// through this one predicate, so the two are guaranteed to drop exactly the +/// same set — the invariant the packaging plugin's "Matching a codegen +/// plugin's output set" note depends on. pub fn package_is_excluded(package: &str, excludes: &[String]) -> bool { excludes.iter().any(|ex| { package == ex @@ -3913,9 +4028,9 @@ pub fn package_is_excluded(package: &str, excludes: &[String]) -> bool { /// could never match a real package, so a typo would otherwise be a silent /// no-op. /// -/// Both protoc plugins parse their `exclude_package` options through this -/// one function so their normalization cannot drift — the same reason they -/// share [`package_is_excluded`]. +/// The plugin option-string parsers and `generate_with_diagnostics` all run +/// entries through this function so normalization cannot drift — the same +/// reason they share [`package_is_excluded`]. /// /// # Errors /// diff --git a/buffa-codegen/src/tests/generation.rs b/buffa-codegen/src/tests/generation.rs index ce1efb9f..a8271bc7 100644 --- a/buffa-codegen/src/tests/generation.rs +++ b/buffa-codegen/src/tests/generation.rs @@ -2675,3 +2675,240 @@ fn test_editions_legacy_required_explicit_default_survives() { "LEGACY_REQUIRED explicit default must reach clear(): {content}" ); } + +// ---- exclude_packages filtering in generate_with_diagnostics --------------- + +fn make_file_with_package(name: &str, package: &str) -> FileDescriptorProto { + FileDescriptorProto { + name: Some(name.to_string()), + package: Some(package.to_string()), + syntax: Some("proto3".to_string()), + ..Default::default() + } +} + +#[test] +fn test_exclude_package_matched_nothing_warning() { + // A typo'd or stale exclude entry must produce a warning rather than a + // silent no-op, so the user discovers the misconfiguration at build time. + let fds = vec![make_file_with_package("example/foo.proto", "example")]; + let to_generate = vec!["example/foo.proto".to_string()]; + let config = CodeGenConfig { + // "buf.validat" is a typo — it matches nothing in the descriptor set. + exclude_packages: vec!["buf.validat".to_string()], + ..Default::default() + }; + let (_, warnings) = + generate_with_diagnostics(&fds, &to_generate, &config).expect("generation failed"); + assert!( + warnings.iter().any(|w| matches!( + w, + CodeGenWarning::ExcludePackageMatchedNothing { package } + if package == "buf.validat" + )), + "expected ExcludePackageMatchedNothing warning; got: {warnings:?}" + ); +} + +#[test] +fn test_exclude_packages_drops_matching_files() { + // Files in `buf.validate` should be dropped; the one in `example` kept. + let fds = vec![ + make_file_with_package("validate/validate.proto", "buf.validate"), + make_file_with_package("example/service.proto", "example"), + ]; + let to_generate = vec![ + "validate/validate.proto".to_string(), + "example/service.proto".to_string(), + ]; + let config = CodeGenConfig { + exclude_packages: vec!["buf.validate".to_string()], + ..Default::default() + }; + let files = generate(&fds, &to_generate, &config).expect("generation failed"); + let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect(); + assert!( + !names.iter().any(|n| n.contains("validate")), + "excluded package must produce no output files; got {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("example")), + "kept package must produce output files; got {names:?}" + ); +} + +#[test] +fn test_exclude_packages_drops_subpackages() { + // `gnostic` in the exclude list must also drop `gnostic.openapi.v3`. + let fds = vec![ + make_file_with_package("gnostic/openapi/v3/doc.proto", "gnostic.openapi.v3"), + make_file_with_package("example/foo.proto", "example"), + ]; + let to_generate = vec![ + "gnostic/openapi/v3/doc.proto".to_string(), + "example/foo.proto".to_string(), + ]; + let config = CodeGenConfig { + exclude_packages: vec!["gnostic".to_string()], + ..Default::default() + }; + let files = generate(&fds, &to_generate, &config).expect("generation failed"); + let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect(); + assert!( + !names.iter().any(|n| n.contains("gnostic")), + "subpackage must also be excluded; got {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("example")), + "kept package must produce output; got {names:?}" + ); +} + +#[test] +fn test_exclude_packages_empty_list_keeps_all() { + // With an empty exclude list the fast path kicks in (Cow::Borrowed) and + // all files are generated unchanged. + let fds = vec![ + make_file_with_package("validate/validate.proto", "buf.validate"), + make_file_with_package("example/foo.proto", "example"), + ]; + let to_generate = vec![ + "validate/validate.proto".to_string(), + "example/foo.proto".to_string(), + ]; + let config = CodeGenConfig { + exclude_packages: vec![], + ..Default::default() + }; + let files = generate(&fds, &to_generate, &config).expect("generation failed"); + let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect(); + assert!( + names.iter().any(|n| n.contains("validate")), + "empty exclude list must keep all files; got {names:?}" + ); + assert!( + names.iter().any(|n| n.contains("example")), + "empty exclude list must keep all files; got {names:?}" + ); +} + +#[test] +fn test_exclude_packages_file_without_descriptor_not_dropped_by_filter() { + // A name in `files_to_generate` that has no matching descriptor must not + // be silently dropped by the exclude-packages filter (`None => true`). It + // propagates to the FileNotFound error that follows, which is actionable. + let fds = vec![make_file_with_package("example/foo.proto", "example")]; + let to_generate = vec![ + "example/foo.proto".to_string(), + "orphan/bar.proto".to_string(), // no descriptor + ]; + let config = CodeGenConfig { + exclude_packages: vec!["buf.validate".to_string()], + ..Default::default() + }; + let err = generate(&fds, &to_generate, &config).unwrap_err(); + // The filter must not drop "orphan/bar.proto" — the error comes from the + // FileNotFound check downstream, not from the exclude-packages step. + assert!( + matches!(err, CodeGenError::FileNotFound(ref name) if name == "orphan/bar.proto"), + "expected FileNotFound for the orphan, got: {err:?}" + ); +} + +#[test] +fn test_exclude_packages_invalid_entry_is_rejected() { + let fds = vec![make_file_with_package("foo/foo.proto", "foo")]; + let to_generate = vec!["foo/foo.proto".to_string()]; + + // Empty component (double dot) is invalid. + let config = CodeGenConfig { + exclude_packages: vec!["buf..validate".to_string()], + ..Default::default() + }; + assert!( + generate(&fds, &to_generate, &config).is_err(), + "malformed exclude entry must be rejected" + ); + + // Trailing dot is also invalid. + let config2 = CodeGenConfig { + exclude_packages: vec!["buf.validate.".to_string()], + ..Default::default() + }; + assert!( + generate(&fds, &to_generate, &config2).is_err(), + "trailing-dot exclude entry must be rejected" + ); +} + +#[test] +fn test_exclude_packages_filter_runs_before_context_build() { + // Regression: when the filter ran *after* CodeGenContext::for_generate, + // the context saw `google/protobuf/timestamp.proto` in files_to_generate + // and concluded "we are generating the WKTs ourselves", suppressing the + // auto extern-path injection. The field type then resolved to a local + // super::…::Timestamp that was never emitted → compile error. + // + // With the filter hoisted before context build, the context only sees + // "my/service.proto" and injects the auto `.google.protobuf → + // ::buffa_types::google::protobuf` mapping, so the field resolves to + // ::buffa_types::google::protobuf::Timestamp as expected. + let ts = FileDescriptorProto { + name: Some("google/protobuf/timestamp.proto".to_string()), + package: Some("google.protobuf".to_string()), + syntax: Some("proto3".to_string()), + message_type: vec![DescriptorProto { + name: Some("Timestamp".to_string()), + ..Default::default() + }], + ..Default::default() + }; + let mut svc = proto3_file("my/service.proto"); + svc.package = Some("my.service".to_string()); + svc.dependency = vec!["google/protobuf/timestamp.proto".to_string()]; + svc.message_type.push(DescriptorProto { + name: Some("Event".to_string()), + field: vec![FieldDescriptorProto { + name: Some("created_at".to_string()), + number: Some(1), + label: Some(Label::LABEL_OPTIONAL), + r#type: Some(Type::TYPE_MESSAGE), + type_name: Some(".google.protobuf.Timestamp".to_string()), + ..Default::default() + }], + ..Default::default() + }); + let config = CodeGenConfig { + exclude_packages: vec!["google.protobuf".to_string()], + ..Default::default() + }; + let files = generate( + &[ts, svc], + &[ + "google/protobuf/timestamp.proto".to_string(), + "my/service.proto".to_string(), + ], + &config, + ) + .expect("generation failed"); + + // google.protobuf is excluded — no output for it. + assert!( + files.iter().all(|f| !f.name.contains("google.protobuf")), + "excluded package must produce no output; got {:?}", + files.iter().map(|f| &f.name).collect::>() + ); + + // my/service.proto is kept — find its content. + let content = joined(&files); + assert!( + content.contains("::buffa_types::google::protobuf::Timestamp"), + "WKT extern-path auto-injection must fire when google.protobuf is excluded, \ + so Timestamp resolves to ::buffa_types::…::Timestamp, not a local path: \ + {content}" + ); + assert!( + !content.contains("super::google::protobuf::Timestamp"), + "no local path to the excluded package may be emitted: {content}" + ); +} diff --git a/docs/guide.md b/docs/guide.md index a2b07cb7..ae55f3a3 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -210,6 +210,7 @@ The macro pulls in `OUT_DIR/.mod.rs`, which in turn includes the per | `.json_feature_name(name)` etc. | `"json"`, `"views"`, `"text"`, `"reflect"` | Rename the crate feature a gated impl kind is conditioned on (one setter per kind: `json_feature_name`, `views_feature_name`, `text_feature_name`, `reflect_feature_name`); inert without `gate_impls_on_crate_features`. The renamed feature must be declared in the consuming crate's `[features]` table — an undeclared name leaves the `#[cfg]` permanently false and the impls silently absent | | `.strict_utf8_mapping(bool)` | `false` | Map `utf8_validation = NONE` string fields to `Vec` / `&[u8]` instead of `String` (see [Skipping UTF-8 validation](#skipping-utf-8-validation)) | | `.extern_path(proto, rust)` | — | Map a proto package or a single type to an external Rust path (see below) | +| `.exclude_package(pkg)` | — | Drop a proto package (and its sub-packages) from code generation. Useful when directory globbing pulls in option-only packages (e.g. `buf.validate`) that you don't want Rust types for. A leading dot is accepted and stripped. Pair with `.extern_path` if kept files reference types from the excluded package; the generator emits a `cargo:warning` for each such cross-package reference. | | `.type_name_prefix(prefix)` | `""` | Prepend a PascalCase prefix (`[A-Z][A-Za-z0-9]*`; anything else is rejected at generation time) to every generated message/enum type name (`message User` → `struct RpcUser`); modules, oneof enums, extern-mapped types, and the wire format are unaffected. A crate referencing these types via `extern_path` must spell out the prefixed name (`::crate_a::RpcUser`) | | `.use_bytes_type()` | — | Use `bytes::Bytes` for all bytes fields, including `map` values | | `.use_bytes_type_in(&[...])` | — | Use `bytes::Bytes` for matching bytes fields (same `map` rule) | @@ -635,7 +636,7 @@ Passed via `opt:` (works for `remote:` and `local:`): > - exclude_package=.gnostic > ``` > -> Excluded descriptors stay available for option resolution, but a kept message with a *field* of an excluded type generates a reference to a Rust module that was never emitted. The generator warns about each such field (on plugin stderr, or as a `cargo:warning` from `buffa-build`), naming the file, message, field, and referenced type, so the resulting compile error in generated code is traceable to its cause. If the types are genuinely needed, map them with `extern_path` instead of excluding them. On the buf path, per-plugin `exclude_types:` (a buf.gen.yaml field, not a plugin opt) is an alternative that prunes the descriptors themselves before the plugin runs — note its subpackage semantics differ: use a `pkg.**` glob to cover subpackages, where `exclude_package` covers them automatically. `exclude_package` is a protoc-plugin option only; the `buffa-build`/`build.rs` path does not need it, since there `files()` lists the generate set explicitly. +> Excluded descriptors stay available for option resolution, but a kept message with a *field* of an excluded type generates a reference to a Rust module that was never emitted. The generator warns about each such field (on plugin stderr, or as a `cargo:warning` from `buffa-build`), naming the file, message, field, and referenced type, so the resulting compile error in generated code is traceable to its cause. If the types are genuinely needed, map them with `extern_path` instead of excluding them. On the buf path, per-plugin `exclude_types:` (a buf.gen.yaml field, not a plugin opt) is an alternative that prunes the descriptors themselves before the plugin runs — note its subpackage semantics differ: use a `pkg.**` glob to cover subpackages, where `exclude_package` covers them automatically. `exclude_package` is also available on the `buffa-build`/`build.rs` path as `Config::exclude_package("buf.validate")` — the generate set there is exactly `files()`, so this matters when a glob expands `files()` to include option-only packages. #### Very large schemas diff --git a/protoc-gen-buffa/src/main.rs b/protoc-gen-buffa/src/main.rs index 5f032969..b22e1c55 100644 --- a/protoc-gen-buffa/src/main.rs +++ b/protoc-gen-buffa/src/main.rs @@ -19,7 +19,7 @@ use std::io::{self, Read, Write}; use buffa::Message; use buffa_codegen::generated::compiler::code_generator_response::File as CodeGeneratorResponseFile; use buffa_codegen::generated::compiler::CodeGeneratorResponse; -use buffa_codegen::generated::descriptor::{Edition, FileDescriptorProto}; +use buffa_codegen::generated::descriptor::Edition; use buffa_codegen::{CodeGenConfig, EnumTypeOverride, FeatureOverride}; @@ -108,22 +108,13 @@ fn run() -> Result<(), Box> { // Parse plugin parameters (e.g., "views=true,unknown_fields=false"). let config = parse_config(request.parameter.as_deref().unwrap_or(""))?; - // Drop excluded packages from the generate set. `include_imports` adds - // imported files (WKTs, buf.validate, gnostic, …) to file_to_generate; - // excluding a package skips emitting its files while leaving its - // descriptors in `proto_file` for type resolution. buffa-codegen only - // emits files listed in file_to_generate, so filtering here is enough. - let file_to_generate = filter_excluded_files( - &request.file_to_generate, - &request.proto_file, - &config.exclude_packages, - ); - // Run code generation, forwarding non-fatal warnings to stderr (protoc - // surfaces plugin stderr to the user). + // surfaces plugin stderr to the user). Excluded packages are in + // `config.codegen.exclude_packages`; `generate_with_diagnostics` filters + // them internally so the filtering logic is shared with the buffa-build path. let (generated, warnings) = buffa_codegen::generate_with_diagnostics( &request.proto_file, - &file_to_generate, + &request.file_to_generate, &config.codegen, )?; for warning in &warnings { @@ -154,38 +145,6 @@ fn run() -> Result<(), Box> { Ok(()) } -/// Remove files whose proto package is excluded (see -/// [`buffa_codegen::package_is_excluded`]) from the generate set. -/// -/// A file listed in `file_to_generate` with no matching descriptor is kept — -/// `generate_with_diagnostics` reports the missing descriptor with a clear -/// error, which is more useful than silently dropping it here. -fn filter_excluded_files( - file_to_generate: &[String], - proto_file: &[FileDescriptorProto], - excludes: &[String], -) -> Vec { - if excludes.is_empty() { - return file_to_generate.to_vec(); - } - file_to_generate - .iter() - .filter(|name| { - match proto_file - .iter() - .find(|fd| fd.name.as_deref() == Some(name.as_str())) - { - Some(fd) => !buffa_codegen::package_is_excluded( - fd.package.as_deref().unwrap_or(""), - excludes, - ), - None => true, - } - }) - .cloned() - .collect() -} - /// Write the serialized CodeGeneratorResponse to stdout. fn write_response(response: &CodeGeneratorResponse) -> io::Result<()> { let mut output = Vec::new(); @@ -205,10 +164,11 @@ fn feature_flags() -> u64 { /// Plugin configuration parsed from the parameter string. struct PluginConfig { /// Code generation options passed to buffa-codegen. + /// + /// `exclude_packages` are stored in `codegen.exclude_packages` so the + /// filtering happens inside `generate_with_diagnostics` and is shared + /// uniformly with the `buffa-build` path. codegen: CodeGenConfig, - /// Proto packages to drop from the generate set (see the - /// `exclude_package` option). Normalized to no leading dot. - exclude_packages: Vec, } /// Parse the plugin parameter string into a PluginConfig. @@ -222,13 +182,9 @@ struct PluginConfig { /// --buffa_opt=extern_path=.my.common.Shared=::shared_types::Shared fn parse_config(params: &str) -> Result { let mut codegen = CodeGenConfig::default(); - let mut exclude_packages: Vec = Vec::new(); if params.is_empty() { - return Ok(PluginConfig { - codegen, - exclude_packages, - }); + return Ok(PluginConfig { codegen }); } for param in params.split(',').map(str::trim).filter(|s| !s.is_empty()) { @@ -368,7 +324,9 @@ fn parse_config(params: &str) -> Result { // optional (normalized like extern_path). protoc-gen-buffa-packaging // accepts the same option so the generated mod.rs stays in sync. "exclude_package" => { - exclude_packages.push(buffa_codegen::normalize_exclude_package(value)?); + codegen + .exclude_packages + .push(buffa_codegen::normalize_exclude_package(value)?); } "extern_path" => { // value is "=" @@ -475,10 +433,7 @@ fn parse_config(params: &str) -> Result { ); } - Ok(PluginConfig { - codegen, - exclude_packages, - }) + Ok(PluginConfig { codegen }) } fn parse_bool(key: &str, value: &str) -> Result { @@ -899,13 +854,16 @@ mod tests { #[test] fn exclude_package_with_leading_dot_is_normalized() { let config = parse_config("exclude_package=.buf.validate").unwrap(); - assert_eq!(config.exclude_packages, vec!["buf.validate".to_string()]); + assert_eq!( + config.codegen.exclude_packages, + vec!["buf.validate".to_string()] + ); } #[test] fn exclude_package_without_leading_dot() { let config = parse_config("exclude_package=gnostic").unwrap(); - assert_eq!(config.exclude_packages, vec!["gnostic".to_string()]); + assert_eq!(config.codegen.exclude_packages, vec!["gnostic".to_string()]); } #[test] @@ -913,7 +871,7 @@ mod tests { let config = parse_config("exclude_package=.buf.validate,exclude_package=.gnostic").unwrap(); assert_eq!( - config.exclude_packages, + config.codegen.exclude_packages, vec!["buf.validate".to_string(), "gnostic".to_string()] ); } @@ -921,7 +879,7 @@ mod tests { #[test] fn exclude_package_defaults_empty() { let config = parse_config("").unwrap(); - assert!(config.exclude_packages.is_empty()); + assert!(config.codegen.exclude_packages.is_empty()); } #[test] @@ -932,49 +890,6 @@ mod tests { assert!(err.contains("exclude_package")); } - fn fd(name: &str, package: &str) -> FileDescriptorProto { - FileDescriptorProto { - name: Some(name.into()), - package: Some(package.into()), - ..Default::default() - } - } - - #[test] - fn filter_excluded_files_drops_excluded_packages() { - let protos = vec![ - fd("example/user/v1/user.proto", "example.user.v1"), - fd("buf/validate/validate.proto", "buf.validate"), - fd("gnostic/openapi/v3/openapiv3.proto", "gnostic.openapi.v3"), - ]; - let all: Vec = protos.iter().map(|f| f.name.clone().unwrap()).collect(); - let kept = filter_excluded_files( - &all, - &protos, - &["buf.validate".to_string(), "gnostic".to_string()], - ); - assert_eq!(kept, vec!["example/user/v1/user.proto".to_string()]); - } - - #[test] - fn filter_excluded_files_no_excludes_is_identity() { - let protos = vec![fd("example/user/v1/user.proto", "example.user.v1")]; - let all: Vec = protos.iter().map(|f| f.name.clone().unwrap()).collect(); - assert_eq!(filter_excluded_files(&all, &protos, &[]), all); - } - - #[test] - fn filter_excluded_files_keeps_file_without_descriptor() { - // No descriptor for the entry → kept, so generate() reports the - // missing-descriptor error rather than silently dropping it. - let kept = filter_excluded_files( - &["orphan.proto".to_string()], - &[], - &["buf.validate".to_string()], - ); - assert_eq!(kept, vec!["orphan.proto".to_string()]); - } - #[test] fn text_true() { let config = parse_config("text=true").unwrap();