Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .changes/unreleased/added-20260829-201650.yaml
Original file line number Diff line number Diff line change
@@ -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=<pkg>` 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
163 changes: 163 additions & 0 deletions buffa-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> 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<u8>`.
///
/// Each path is a fully-qualified proto path prefix. Use `"."` to apply
Expand Down Expand Up @@ -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<dyn std::error::Error>> {
// 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
Expand Down Expand Up @@ -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<String> = 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}");
}
}
141 changes: 128 additions & 13 deletions buffa-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<entry>."` — 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<String>,
}

impl Default for CodeGenConfig {
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> = config
.exclude_packages
.iter()
.map(|entry| {
normalize_exclude_package(entry)
.map_err(|e| CodeGenError::Other(format!("exclude_package {entry:?}: {e}")))
})
.collect::<Result<_, _>>()?;
// 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
Expand Down Expand Up @@ -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<String, Vec<&FileDescriptorProto>> =
std::collections::BTreeMap::new();
Expand Down Expand Up @@ -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
Expand All @@ -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
///
Expand Down
Loading
Loading