buffa_codegen/feature_gates.rs
1//! Helpers for emitting `#[cfg(feature = "...")]` / `#[cfg_attr(...)]`
2//! wrappers around generated impls.
3//!
4//! Wired through [`CodeGenConfig::gate_impls_on_crate_features`]. When that
5//! flag is off (the default), every helper is a no-op so the conditional
6//! call-sites in `message.rs`/`enumeration.rs`/`oneof.rs`/etc. produce the
7//! exact same tokens as before — which is what most consumers want: they
8//! decide at build-script time whether to generate JSON, and the resulting
9//! code carries a hard dependency on the runtime support.
10//!
11//! When the flag is on, the json/views/text impls are wrapped in `#[cfg]`
12//! so the consuming crate can feature-gate them. That lets `buffa-descriptor`
13//! and `buffa-types` ship every impl while keeping the codegen toolchain
14//! lean (it deps on them with `default-features = false`).
15//!
16//! [`CodeGenConfig::gate_impls_on_crate_features`]: crate::CodeGenConfig::gate_impls_on_crate_features
17
18use proc_macro2::TokenStream;
19use quote::quote;
20
21use crate::CodeGenConfig;
22
23/// Default crate feature names the gated impls are conditioned on.
24pub(crate) const JSON_FEATURE: &str = "json";
25pub(crate) const VIEWS_FEATURE: &str = "views";
26pub(crate) const TEXT_FEATURE: &str = "text";
27pub(crate) const REFLECT_FEATURE: &str = "reflect";
28
29/// Crate feature names used by the gated impls, customizable per impl kind.
30///
31/// Used by [`CodeGenConfig::feature_gate_names`]. The defaults are `"json"`,
32/// `"views"`, `"text"`, and `"reflect"`; the consuming crate must define
33/// matching features in its `Cargo.toml`. Override a name when the consuming
34/// crate already uses a different feature name for the same concern (e.g. a
35/// crate whose JSON support is gated behind a `serde` feature).
36///
37/// Names are emitted verbatim into `#[cfg(feature = "...")]` /
38/// `#[cfg_attr(feature = "...", ...)]` attributes; they must be valid Cargo
39/// feature names. **An empty, misspelled, or undeclared name fails open**:
40/// the emitted `#[cfg]` is permanently false in the consuming crate, so the
41/// gated impls silently compile away. To catch the cases that are
42/// detectable at generation time, [`generate`](crate::generate) returns an
43/// error when an *active* gate name is empty or not a valid Cargo feature
44/// name; an undeclared (but valid) name can only be diagnosed in the
45/// consuming crate, via the `unexpected_cfgs` lint on Rust ≥ 1.80.
46///
47/// The struct is `#[non_exhaustive]`; construct it by mutating
48/// [`FeatureGateNames::default()`] (or use the `buffa_build::Config`
49/// setters).
50///
51/// [`CodeGenConfig::feature_gate_names`]: crate::CodeGenConfig::feature_gate_names
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub struct FeatureGateNames {
55 /// Feature gating the serde JSON impls (default `"json"`).
56 pub json: String,
57 /// Feature gating the view types and impls (default `"views"`).
58 pub views: String,
59 /// Feature gating the textproto impls (default `"text"`).
60 pub text: String,
61 /// Feature gating the reflection impls (default `"reflect"`).
62 pub reflect: String,
63}
64
65impl FeatureGateNames {
66 /// Whether `name` is a valid Cargo feature name: starts with an ASCII
67 /// alphanumeric or `_`, with the remainder drawn from alphanumerics,
68 /// `_`, `-`, `+`, and `.`.
69 ///
70 /// This is the rule [`generate`](crate::generate) enforces on every
71 /// *active* gate name. It is public so toolchains layered on
72 /// buffa-codegen (e.g. service generators with their own feature-gate
73 /// knobs) can validate user-supplied names against the same rule
74 /// instead of re-deriving it.
75 #[must_use]
76 pub fn is_valid_name(name: &str) -> bool {
77 is_valid_feature_name(name)
78 }
79}
80
81impl Default for FeatureGateNames {
82 fn default() -> Self {
83 Self {
84 json: JSON_FEATURE.to_string(),
85 views: VIEWS_FEATURE.to_string(),
86 text: TEXT_FEATURE.to_string(),
87 reflect: REFLECT_FEATURE.to_string(),
88 }
89 }
90}
91
92/// Whether `name` is a valid Cargo feature name: starts with an ASCII
93/// alphanumeric or `_`, with the remainder drawn from Cargo's feature
94/// alphabet (alphanumerics, `_`, `-`, `+`, `.`).
95///
96/// Enforced at the [`generate`](crate::generate) entry point for every
97/// *active* gate name — the failure mode of an invalid name is silent
98/// (`#[cfg(feature = "")]` is permanently false, so the gated impls just
99/// disappear), so it must be a hard error in every build profile.
100pub(crate) fn is_valid_feature_name(name: &str) -> bool {
101 let mut chars = name.chars();
102 chars
103 .next()
104 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
105 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
106}
107
108/// Resolved feature-gate names for the current codegen run, computed once
109/// from [`CodeGenConfig`] and threaded through codegen call-sites.
110///
111/// Each field is `Some("name")` when the corresponding impl kind is both
112/// enabled (`generate_*` is true) and gated
113/// (`gate_impls_on_crate_features` is true), and `None` otherwise. The names
114/// borrow from the config's [`FeatureGateNames`]. Pass the field to
115/// [`cfg_block`] / [`cfg_attr`] to wrap a token stream — they're no-ops on
116/// `None`.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub(crate) struct FeatureGates<'a> {
119 pub(crate) json: Option<&'a str>,
120 pub(crate) views: Option<&'a str>,
121 pub(crate) text: Option<&'a str>,
122 pub(crate) reflect: Option<&'a str>,
123}
124
125impl<'a> FeatureGates<'a> {
126 /// Compute the active gates for a config.
127 ///
128 /// `gate_impls_on_crate_features` gates json/views/text/reflect together.
129 /// When it is off, `gate_reflect_on_crate_feature` turns on reflect-only
130 /// gating — for crates (notably `buffa-types`) that ship views/text
131 /// unconditionally but want the `buffa-descriptor`-dependent reflection
132 /// surface to be opt-in.
133 pub(crate) fn for_config(config: &'a CodeGenConfig) -> Self {
134 let gate_all = config.gate_impls_on_crate_features;
135 let gate_reflect = gate_all || config.gate_reflect_on_crate_feature;
136 let names = &config.feature_gate_names;
137 Self {
138 json: (gate_all && config.generate_json).then_some(names.json.as_str()),
139 views: (gate_all && config.generate_views).then_some(names.views.as_str()),
140 text: (gate_all && config.generate_text).then_some(names.text.as_str()),
141 reflect: (gate_reflect && config.generate_reflection).then_some(names.reflect.as_str()),
142 }
143 }
144
145 /// Check every *active* gate name against [`is_valid_feature_name`],
146 /// returning the first offender as `Err((kind, name))`.
147 ///
148 /// Called from [`generate`](crate::generate) so an invalid name is a
149 /// hard error in every build profile — the failure mode otherwise is
150 /// silent (the emitted `#[cfg]` is permanently false and the gated
151 /// impls compile away). Inactive names are not checked: they are inert
152 /// and never reach the output.
153 pub(crate) fn validate(&self) -> Result<(), (&'static str, &'a str)> {
154 [
155 ("json", self.json),
156 ("views", self.views),
157 ("text", self.text),
158 ("reflect", self.reflect),
159 ]
160 .into_iter()
161 .filter_map(|(kind, name)| Some((kind, name?)))
162 .try_for_each(|(kind, name)| {
163 if is_valid_feature_name(name) {
164 Ok(())
165 } else {
166 Err((kind, name))
167 }
168 })
169 }
170
171 /// `Some("json")`, `Some("text")`, or — when both are active — the
172 /// composite gate for items that exist iff *either* json or text is on
173 /// (e.g. `register_types`, whose body registers both kinds of entry).
174 ///
175 /// Returns `None` when neither is gated. When both kinds are gated on
176 /// the *same* custom name, the duplicate collapses to a single entry so
177 /// the emitted attribute is a plain `#[cfg(feature = "...")]` rather
178 /// than `#[cfg(any(feature = "x", feature = "x"))]`. The caller should
179 /// pass this to [`cfg_block_any`] to handle the two-feature case.
180 pub(crate) fn json_or_text(&self) -> Vec<&'a str> {
181 let mut v = Vec::with_capacity(2);
182 if let Some(f) = self.json {
183 v.push(f);
184 }
185 if let Some(f) = self.text {
186 if self.json != Some(f) {
187 v.push(f);
188 }
189 }
190 v
191 }
192}
193
194/// Wrap `tokens` in `#[cfg(feature = "<gate>")]` when `gate` is `Some`.
195///
196/// Use for **a single item or statement**: an `impl` block, a struct/enum
197/// definition, a `pub use` re-export, a `pub mod` declaration, a `const`
198/// item, or one statement inside a fn body. A `#[cfg]` outer attribute
199/// attaches only to the **next** item — if `tokens` contains multiple
200/// siblings, only the first is gated and the rest leak ungated, which is a
201/// silent correctness bug. Use [`cfg_const_block`] for sibling impls, or
202/// wrap each individually.
203///
204/// Debug builds assert `tokens` parses as a single `syn::Item` or
205/// `syn::Stmt` to catch multi-item misuse early.
206pub(crate) fn cfg_block(tokens: TokenStream, gate: Option<&str>) -> TokenStream {
207 match gate {
208 Some(feature) if !tokens.is_empty() => {
209 debug_assert!(
210 syn::parse2::<syn::Item>(tokens.clone()).is_ok()
211 || syn::parse2::<syn::Stmt>(tokens.clone()).is_ok(),
212 "cfg_block applied to a token stream that is not a single item/statement; \
213 trailing siblings would leak ungated. Use cfg_const_block. tokens: {tokens}"
214 );
215 quote! {
216 #[cfg(feature = #feature)]
217 #tokens
218 }
219 }
220 _ => tokens,
221 }
222}
223
224/// Wrap `tokens` in `#[cfg(any(feature = "a", feature = "b", ...))]`.
225///
226/// Use for an item that should exist iff *at least one* of a set of gated
227/// modes is enabled — e.g. `register_types`, which registers both JSON and
228/// text entries and is useful when either is on. No-op for an empty set;
229/// degenerates to a single `#[cfg(feature = "a")]` for a one-element set
230/// (functionally identical to `cfg(any(feature = "a"))`, just less noise).
231pub(crate) fn cfg_block_any(tokens: TokenStream, gates: &[&str]) -> TokenStream {
232 match gates {
233 [] => tokens,
234 [single] => cfg_block(tokens, Some(single)),
235 many if !tokens.is_empty() => {
236 let preds = many.iter().map(|f| quote! { feature = #f });
237 quote! {
238 #[cfg(any(#(#preds),*))]
239 #tokens
240 }
241 }
242 _ => tokens,
243 }
244}
245
246/// Wrap a token stream of multiple **sibling items** in a single
247/// `#[cfg(feature = "<gate>")]` by enclosing them in an anonymous
248/// `const _: () = { ... };` block.
249///
250/// A bare `#[cfg(...)]` outer attribute attaches only to the next item.
251/// Wrapping in `const _: () = { ... }` lets one `#[cfg]` cover the lot —
252/// the anonymous const is an item itself, and `impl` blocks inside it
253/// register on the global type they target exactly as they would at
254/// module scope. No-op for `None`.
255pub(crate) fn cfg_const_block(tokens: TokenStream, gate: Option<&str>) -> TokenStream {
256 match gate {
257 Some(feature) if !tokens.is_empty() => quote! {
258 #[cfg(feature = #feature)]
259 const _: () = {
260 #tokens
261 };
262 },
263 _ => tokens,
264 }
265}
266
267/// Wrap `attr_body` in `#[cfg_attr(feature = "<gate>", <attr_body>)]` when
268/// `gate` is `Some`, or `#[<attr_body>]` when `None`.
269///
270/// Use for derives and helper attributes that must only apply when the
271/// feature is on — e.g. `derive(::serde::Serialize, ::serde::Deserialize)`,
272/// `serde(default)`, `serde(rename = "...")`. Without the gate, a
273/// `#[serde(...)]` field attribute on a struct that doesn't
274/// `#[derive(Serialize)]` (because the derive itself was gated off) is a
275/// hard compile error — `serde` is a derive helper attribute and isn't in
276/// scope without the derive.
277///
278/// Returns an empty stream for an empty `attr_body` so call-sites can build
279/// up attribute lists with conditional pieces without spurious `#[]`.
280pub(crate) fn cfg_attr(attr_body: TokenStream, gate: Option<&str>) -> TokenStream {
281 if attr_body.is_empty() {
282 return TokenStream::new();
283 }
284 match gate {
285 Some(feature) => quote! { #[cfg_attr(feature = #feature, #attr_body)] },
286 None => quote! { #[#attr_body] },
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 fn gated_config() -> CodeGenConfig {
295 CodeGenConfig {
296 generate_json: true,
297 generate_views: true,
298 generate_text: true,
299 gate_impls_on_crate_features: true,
300 ..CodeGenConfig::default()
301 }
302 }
303
304 #[test]
305 fn for_config_off_by_default() {
306 let config = CodeGenConfig {
307 generate_json: true,
308 generate_views: true,
309 generate_text: true,
310 ..CodeGenConfig::default()
311 };
312 assert_eq!(FeatureGates::for_config(&config), FeatureGates::default());
313 }
314
315 #[test]
316 fn for_config_gates_only_enabled_kinds() {
317 // `generate_text` off → `text` gate is `None` even with
318 // `gate_impls_on_crate_features` on. The flag controls *how* an
319 // impl is emitted, not *whether*.
320 let config = CodeGenConfig {
321 generate_json: true,
322 generate_views: false,
323 generate_text: false,
324 gate_impls_on_crate_features: true,
325 ..CodeGenConfig::default()
326 };
327 let gates = FeatureGates::for_config(&config);
328 assert_eq!(gates.json, Some(JSON_FEATURE));
329 assert_eq!(gates.views, None);
330 assert_eq!(gates.text, None);
331 }
332
333 #[test]
334 fn for_config_all_gated() {
335 let config = gated_config();
336 let gates = FeatureGates::for_config(&config);
337 assert_eq!(gates.json, Some(JSON_FEATURE));
338 assert_eq!(gates.views, Some(VIEWS_FEATURE));
339 assert_eq!(gates.text, Some(TEXT_FEATURE));
340 assert_eq!(gates.json_or_text(), vec![JSON_FEATURE, TEXT_FEATURE]);
341 }
342
343 #[test]
344 fn for_config_reflect_only_gate() {
345 // `gate_reflect_on_crate_feature` gates reflect without gating
346 // json/views/text — the `buffa-types` shape.
347 let config = CodeGenConfig {
348 generate_json: true,
349 generate_views: true,
350 generate_text: true,
351 generate_reflection: true,
352 gate_reflect_on_crate_feature: true,
353 ..CodeGenConfig::default()
354 };
355 let gates = FeatureGates::for_config(&config);
356 assert_eq!(gates.json, None);
357 assert_eq!(gates.views, None);
358 assert_eq!(gates.text, None);
359 assert_eq!(gates.reflect, Some(REFLECT_FEATURE));
360 }
361
362 #[test]
363 fn for_config_reflect_gate_requires_generate_reflection() {
364 // The gate flag is inert unless reflection is actually generated.
365 let config = CodeGenConfig {
366 generate_reflection: false,
367 gate_reflect_on_crate_feature: true,
368 ..CodeGenConfig::default()
369 };
370 assert_eq!(FeatureGates::for_config(&config).reflect, None);
371 }
372
373 #[test]
374 fn for_config_umbrella_gate_includes_reflect() {
375 // `gate_impls_on_crate_features` also gates reflect when reflection is on.
376 let config = CodeGenConfig {
377 generate_reflection: true,
378 gate_impls_on_crate_features: true,
379 ..CodeGenConfig::default()
380 };
381 assert_eq!(
382 FeatureGates::for_config(&config).reflect,
383 Some(REFLECT_FEATURE)
384 );
385 }
386
387 #[test]
388 fn for_config_custom_names() {
389 let config = CodeGenConfig {
390 feature_gate_names: FeatureGateNames {
391 json: "serde".to_string(),
392 views: "zero-copy".to_string(),
393 text: "textproto".to_string(),
394 reflect: "reflection".to_string(),
395 },
396 generate_reflection: true,
397 ..gated_config()
398 };
399 let gates = FeatureGates::for_config(&config);
400 assert_eq!(gates.json, Some("serde"));
401 assert_eq!(gates.views, Some("zero-copy"));
402 assert_eq!(gates.text, Some("textproto"));
403 assert_eq!(gates.reflect, Some("reflection"));
404 assert_eq!(gates.json_or_text(), vec!["serde", "textproto"]);
405 }
406
407 #[test]
408 fn custom_names_inert_without_gating() {
409 // Renaming a gate without enabling gating changes nothing — the
410 // names only matter once `gate_impls_on_crate_features` (or the
411 // reflect-only flag) turns the gates on.
412 let config = CodeGenConfig {
413 generate_json: true,
414 feature_gate_names: FeatureGateNames {
415 json: "serde".to_string(),
416 ..FeatureGateNames::default()
417 },
418 ..CodeGenConfig::default()
419 };
420 assert_eq!(FeatureGates::for_config(&config), FeatureGates::default());
421 }
422
423 #[test]
424 fn json_or_text_dedups_shared_name() {
425 // Gating both kinds behind one feature must emit a single
426 // `#[cfg(feature = "serde")]`, not `#[cfg(any(.., ..))]` with a
427 // duplicated predicate.
428 let shared = FeatureGates {
429 json: Some("serde"),
430 text: Some("serde"),
431 ..Default::default()
432 };
433 assert_eq!(shared.json_or_text(), vec!["serde"]);
434 }
435
436 #[test]
437 fn public_validator_matches_internal_rule() {
438 // The public surface for layered toolchains must agree with what
439 // `generate` enforces.
440 for name in ["json", "zero-copy", "", "-leading", "with space"] {
441 assert_eq!(
442 FeatureGateNames::is_valid_name(name),
443 is_valid_feature_name(name)
444 );
445 }
446 }
447
448 #[test]
449 fn feature_name_validity() {
450 assert!(is_valid_feature_name("json"));
451 assert!(is_valid_feature_name("zero-copy"));
452 assert!(is_valid_feature_name("a_b.c+d2"));
453 assert!(is_valid_feature_name("_private"));
454 assert!(!is_valid_feature_name(""));
455 assert!(!is_valid_feature_name("with space"));
456 assert!(!is_valid_feature_name("quo\"te"));
457 // Cargo requires the first character to be alphanumeric or `_`.
458 assert!(!is_valid_feature_name("-leading"));
459 assert!(!is_valid_feature_name(".leading"));
460 assert!(!is_valid_feature_name("+leading"));
461 }
462
463 #[test]
464 fn validate_reports_first_invalid_active_name() {
465 let config = CodeGenConfig {
466 feature_gate_names: FeatureGateNames {
467 views: String::new(),
468 ..FeatureGateNames::default()
469 },
470 ..gated_config()
471 };
472 assert_eq!(
473 FeatureGates::for_config(&config).validate(),
474 Err(("views", ""))
475 );
476 }
477
478 #[test]
479 fn validate_ignores_inactive_invalid_names() {
480 // An invalid name on a kind that isn't gated never reaches the
481 // output, so it must not fail validation.
482 let config = CodeGenConfig {
483 feature_gate_names: FeatureGateNames {
484 reflect: "not valid".to_string(),
485 ..FeatureGateNames::default()
486 },
487 ..gated_config() // generate_reflection is off in gated_config
488 };
489 let gates = FeatureGates::for_config(&config);
490 assert_eq!(gates.reflect, None);
491 assert_eq!(gates.validate(), Ok(()));
492 }
493
494 #[test]
495 fn default_names_match_constants() {
496 let names = FeatureGateNames::default();
497 assert_eq!(names.json, JSON_FEATURE);
498 assert_eq!(names.views, VIEWS_FEATURE);
499 assert_eq!(names.text, TEXT_FEATURE);
500 assert_eq!(names.reflect, REFLECT_FEATURE);
501 }
502
503 #[test]
504 fn json_or_text_subsets() {
505 let none = FeatureGates::default();
506 assert!(none.json_or_text().is_empty());
507 let json_only = FeatureGates {
508 json: Some(JSON_FEATURE),
509 ..Default::default()
510 };
511 assert_eq!(json_only.json_or_text(), vec![JSON_FEATURE]);
512 let text_only = FeatureGates {
513 text: Some(TEXT_FEATURE),
514 ..Default::default()
515 };
516 assert_eq!(text_only.json_or_text(), vec![TEXT_FEATURE]);
517 }
518
519 #[test]
520 fn cfg_block_any_dispatches_by_arity() {
521 let inner = quote! { pub fn f() {} };
522 // Empty set → passthrough.
523 assert_eq!(
524 cfg_block_any(inner.clone(), &[]).to_string(),
525 inner.to_string()
526 );
527 // One element → plain `cfg(feature = "...")`.
528 assert_eq!(
529 cfg_block_any(inner.clone(), &["json"]).to_string(),
530 quote! { #[cfg(feature = "json")] pub fn f() {} }.to_string()
531 );
532 // Two elements → `cfg(any(...))`.
533 assert_eq!(
534 cfg_block_any(inner.clone(), &["json", "text"]).to_string(),
535 quote! { #[cfg(any(feature = "json", feature = "text"))] pub fn f() {} }.to_string()
536 );
537 assert!(cfg_block_any(TokenStream::new(), &["json", "text"]).is_empty());
538 }
539
540 #[test]
541 #[should_panic(expected = "cfg_block applied to a token stream that is not a single item")]
542 #[cfg(debug_assertions)]
543 fn cfg_block_rejects_multiple_siblings() {
544 // Two sibling items → would silently leave the second ungated. The
545 // debug_assert catches this misuse early.
546 cfg_block(quote! { struct A; struct B; }, Some("json"));
547 }
548
549 #[test]
550 fn cfg_block_wraps_when_gated() {
551 let inner = quote! { impl Foo for Bar {} };
552 let wrapped = cfg_block(inner.clone(), Some("json"));
553 assert_eq!(
554 wrapped.to_string(),
555 quote! { #[cfg(feature = "json")] impl Foo for Bar {} }.to_string()
556 );
557 // No gate → passthrough.
558 assert_eq!(
559 cfg_block(inner.clone(), None).to_string(),
560 inner.to_string()
561 );
562 // Empty input → empty output, no dangling `#[cfg]`.
563 assert!(cfg_block(TokenStream::new(), Some("json")).is_empty());
564 }
565
566 #[test]
567 fn cfg_const_block_wraps_siblings() {
568 let inner = quote! { impl A for X {} impl B for X {} };
569 let wrapped = cfg_const_block(inner.clone(), Some("json"));
570 assert_eq!(
571 wrapped.to_string(),
572 quote! {
573 #[cfg(feature = "json")]
574 const _: () = { impl A for X {} impl B for X {} };
575 }
576 .to_string()
577 );
578 assert_eq!(
579 cfg_const_block(inner.clone(), None).to_string(),
580 inner.to_string()
581 );
582 assert!(cfg_const_block(TokenStream::new(), Some("json")).is_empty());
583 }
584
585 #[test]
586 fn cfg_attr_wraps_when_gated() {
587 let body = quote! { derive(::serde::Serialize) };
588 assert_eq!(
589 cfg_attr(body.clone(), Some("json")).to_string(),
590 quote! { #[cfg_attr(feature = "json", derive(::serde::Serialize))] }.to_string()
591 );
592 assert_eq!(
593 cfg_attr(body.clone(), None).to_string(),
594 quote! { #[derive(::serde::Serialize)] }.to_string()
595 );
596 assert!(cfg_attr(TokenStream::new(), Some("json")).is_empty());
597 assert!(cfg_attr(TokenStream::new(), None).is_empty());
598 }
599}