Skip to main content

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}