Skip to main content

buffa_codegen/
context.rs

1//! Code generation context and descriptor-to-Rust mapping state.
2
3use std::borrow::Cow;
4use std::collections::{HashMap, HashSet};
5
6use crate::features::{self, ResolvedFeatures};
7use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
8use crate::oneof::to_snake_case;
9use crate::CodeGenConfig;
10
11/// The single reserved module name under which all ancillary generated types
12/// (views, oneof enums, extensions, `register_types`) live.
13///
14/// See `DESIGN.md` → "Generated code layout" for the full layout. The name
15/// is checked against proto package segments and message-module names by
16/// `validate_file`; a collision is a hard error.
17pub const SENTINEL_MOD: &str = "__buffa";
18
19/// A Rust type path split at the target-package boundary.
20///
21/// Returned by [`CodeGenContext::rust_type_relative_split`]. The full owned
22/// path is `to_package + within_package` (concatenated with `::`); ancillary
23/// kinds insert their `__buffa::<kind>::` prefix between the two halves.
24#[derive(Debug, Clone)]
25pub struct SplitPath {
26    /// Path from the current emission scope to the **target package root**.
27    ///
28    /// One of:
29    /// - empty (same package, nesting 0)
30    /// - `"super::super"` (same package, nesting > 0)
31    /// - `"super::…::other_pkg"` (cross-package local)
32    /// - `"::extern_crate::pkg"` (extern type — absolute, nesting-independent)
33    pub to_package: String,
34    /// Path from the target package root to the type itself
35    /// (e.g. `"Foo"` or `"outer::Inner"`).
36    pub within_package: String,
37    /// `true` when `to_package` is an absolute (`::`/`crate::`) extern path.
38    /// Extern paths don't depend on the caller's nesting depth.
39    pub is_extern: bool,
40}
41
42/// Shared context for a code generation run.
43///
44/// Holds the full set of file descriptors and a mapping from fully-qualified
45/// protobuf type names to their Rust type paths. This is needed because a
46/// field in one `.proto` file may reference a message defined in another.
47pub struct CodeGenContext<'a> {
48    /// All file descriptors (both requested and dependencies).
49    pub files: &'a [FileDescriptorProto],
50    /// Code generation configuration.
51    pub config: &'a CodeGenConfig,
52    /// Map from fully-qualified protobuf name (e.g., ".my.package.MyMessage")
53    /// to Rust type path (e.g., "my::package::MyMessage").
54    ///
55    /// Nested types use module-qualified paths:
56    /// ".pkg.Outer.Inner" → "pkg::outer::Inner" (not "pkg::OuterInner").
57    pub type_map: HashMap<String, String>,
58    /// Map from fully-qualified protobuf name to its proto package.
59    ///
60    /// Used by `rust_type_relative` to compute `super::`-based relative
61    /// paths for cross-package references within the same compilation.
62    package_of: HashMap<String, String>,
63    /// Map from fully-qualified enum name to its resolved `enum_type` feature.
64    ///
65    /// The `enum_type` feature determines whether an enum is OPEN or CLOSED.
66    /// It's resolved from the ENUM's own file → message → enum feature chain,
67    /// NOT from the referencing field's chain. protoc does not propagate
68    /// enum-level `enum_type` into field options (verified 2026-03), so
69    /// callers must look this up via `is_enum_closed`.
70    enum_closedness: HashMap<String, bool>,
71    /// Map from fully-qualified enum name to its first declared value's
72    /// number — the enum type's implicit default per spec. Non-zero only for
73    /// closed (proto2 / editions-closed) enums; used to decide whether a bare
74    /// enum field opened by an enum-type override needs an explicit generated
75    /// default (`EnumValue::Known(first)` instead of the derived wire-zero).
76    enum_first_value: HashMap<String, i32>,
77    /// Map from fully-qualified protobuf element name to its source comment.
78    ///
79    /// Keys use dotted FQN form without a leading dot, matching the `proto_fqn`
80    /// values already threaded through codegen: `"pkg.Message"`,
81    /// `"pkg.Message.field_name"`, `"pkg.Enum.VALUE_NAME"`,
82    /// `"pkg.Message.oneof_name"`.
83    ///
84    /// Built by walking each file's descriptor tree alongside its
85    /// `SourceCodeInfo` (which uses index-based paths). This up-front
86    /// translation means codegen call sites can look up comments by the
87    /// proto FQN they already have, rather than threading index-based paths
88    /// through every function signature.
89    comment_map: HashMap<String, String>,
90    /// Deconflicted module name for each top-level message, keyed by the
91    /// leading-dot FQN (`".pkg.Msg"`).
92    ///
93    /// A message's nested types live in a `snake_case(Name)` submodule. When
94    /// that name would collide with a sub-package module in the same scope
95    /// (proto is case-sensitive, so `message Oof` and `package foo.oof` both map
96    /// to `mod oof`), a trailing `_` is appended until the name is unique within
97    /// the scope's occupied set. Entries exist for every top-level message; the
98    /// value equals `snake_case(Name)` when no deconfliction was needed.
99    nested_module_names: HashMap<String, String>,
100    /// Variant paths (leading-dot form) resolved from
101    /// `config.unboxed_oneof_fields` whose oneof variants are stored inline.
102    /// Built once by [`resolve_unboxed_variants`](crate::oneof::resolve_unboxed_variants);
103    /// never contains recursive variants. See [`oneof_unboxed`](Self::oneof_unboxed).
104    unboxed_oneof_variants: HashSet<String>,
105    /// Singular message-field paths (leading-dot form) whose resolved
106    /// [`PointerRepr`](crate::PointerRepr) is `Inline`. Built once by
107    /// [`resolve_inlined_fields`](crate::oneof::resolve_inlined_fields); never
108    /// contains recursive fields. [`pointer_repr`](Self::pointer_repr) demotes
109    /// any raw `Inline` not in this set to `Box`.
110    inlined_message_fields: HashSet<String>,
111    /// Non-fatal diagnostics accumulated during generation (e.g. an enum whose
112    /// idiomatic CamelCase aliases were suppressed by a naming conflict).
113    ///
114    /// Interior-mutable so deeply-nested codegen helpers can record a warning
115    /// through the shared `&CodeGenContext` without threading a sink through
116    /// every signature. Drained by [`generate_with_diagnostics`] after
117    /// generation; callers surface them as build warnings. The `RefCell` commits
118    /// the context to single-threaded use; switch to a `Mutex` if package
119    /// generation is ever parallelized.
120    ///
121    /// [`generate_with_diagnostics`]: crate::generate_with_diagnostics
122    warnings: std::cell::RefCell<Vec<crate::CodeGenWarning>>,
123    /// Field-rename exceptions for `CodeGenConfig::idiomatic_field_names`:
124    /// `(proto_name, field_number)` → final Rust source name, present only
125    /// for fields whose context-free snake_case conversion was adjusted by a
126    /// collision (see [`crate::field_names`]). Empty when the option is off
127    /// or no collisions exist — the common case.
128    field_renames: HashMap<(String, i32), String>,
129    /// Oneof proto names that keep their verbatim spelling under
130    /// `idiomatic_field_names` (their snake_case conversion collided).
131    oneof_keep_verbatim: HashSet<String>,
132    /// Package-root import phase for `CodeGenConfig::idiomatic_imports`
133    /// (file_per_package mode only). [`ImportsPhase::Off`] outside the
134    /// two-pass window, so every path resolver below is a no-op by default.
135    ///
136    /// Interior-mutable for the same reason as `warnings`: the leaf path
137    /// resolvers ([`rust_type_relative`](Self::rust_type_relative),
138    /// [`root_runtime_path`](Self::root_runtime_path)) participate through
139    /// the shared `&CodeGenContext` without threading a registry through
140    /// every emission signature.
141    ///
142    /// [`ImportsPhase::Off`]: crate::imports::ImportsPhase::Off
143    imports: std::cell::RefCell<crate::imports::ImportsPhase>,
144}
145
146/// The immediate child-package segment names directly under `package`.
147///
148/// For `package` `"foo"` and known packages `{foo, foo.oof, foo.bar.baz}`, this
149/// is `{"oof", "bar"}` — the first segment after the `"foo."` prefix of each
150/// deeper package. These are exactly the sub-package module names that will be
151/// siblings of `foo`'s message-nesting modules.
152fn child_package_segments(package: &str, all_packages: &HashSet<String>) -> HashSet<String> {
153    let prefix = if package.is_empty() {
154        String::new()
155    } else {
156        format!("{package}.")
157    };
158    all_packages
159        .iter()
160        .filter_map(|p| {
161            let rest = if package.is_empty() {
162                Some(p.as_str())
163            } else {
164                p.strip_prefix(&prefix)
165            };
166            rest.filter(|r| !r.is_empty())
167                .map(|r| r.split('.').next().unwrap_or(r).to_string())
168        })
169        .collect()
170}
171
172/// Deconflict the nested-types module names for one package's top-level
173/// messages against the sub-package modules in the same scope (issue #135).
174///
175/// `message_names` are the package's top-level message names in declaration
176/// order; `children` are the sub-package segment names that share the package's
177/// module scope. Returns one module name per input message, in the same order.
178///
179/// Each name is `snake_case(Name)` unless it collides with a sub-package
180/// segment, in which case `_` is appended until the candidate is unique against:
181/// the sub-package segments, every message's raw module name, the `__buffa`
182/// sentinel, **and every already-assigned deconflicted name**. That last set —
183/// threaded through the shared `taken` set — is what keeps two messages that
184/// would otherwise race to the same slot distinct (e.g. `Oof` and `Oof_`
185/// alongside sub-packages `oof` and `oof_` resolve to `oof__` and `oof___`,
186/// never both to `oof__`).
187///
188/// Colliding messages are assigned in a stable order (sorted by base name), so
189/// the per-message result is independent of declaration order — reordering the
190/// input files or messages never changes which name a given message receives.
191fn deconflict_package_modules(message_names: &[String], children: &HashSet<String>) -> Vec<String> {
192    let bases: Vec<String> = message_names.iter().map(|n| to_snake_case(n)).collect();
193    // Seed with everything fixed: sub-package segments, the sentinel, and every
194    // message's raw module name. Assigned deconflicted names are added as we go.
195    let mut taken: HashSet<String> = children.clone();
196    taken.insert(SENTINEL_MOD.to_string());
197    taken.extend(bases.iter().cloned());
198
199    // Result starts as the raw bases (correct for every non-colliding message),
200    // and colliding messages overwrite their slot. Assign in a stable order
201    // (sorted by base name) so the per-message suffix is independent of
202    // declaration order; two colliding messages can't both grab the same slot.
203    let mut out = bases.clone();
204    let mut order: Vec<usize> = (0..bases.len()).collect();
205    order.sort_by(|&a, &b| bases[a].cmp(&bases[b]));
206    for i in order {
207        if !children.contains(&bases[i]) {
208            continue;
209        }
210        let mut candidate = format!("{}_", bases[i]);
211        while taken.contains(&candidate) {
212            candidate.push('_');
213        }
214        taken.insert(candidate.clone());
215        out[i] = candidate;
216    }
217    out
218}
219
220impl<'a> CodeGenContext<'a> {
221    /// Build a context from file descriptors, populating the type map.
222    ///
223    /// `effective_extern_paths` includes both user-provided mappings and any
224    /// auto-injected defaults (e.g., the WKT mapping). These are computed by
225    /// `crate::effective_extern_paths` before calling this constructor.
226    ///
227    /// File-level extern resolution (currently only `descriptor.proto` /
228    /// `compiler/plugin.proto` → `buffa-descriptor`) is **not** applied by
229    /// this constructor — use [`for_generate`](Self::for_generate), which
230    /// computes and passes the file-level mappings, when generating code
231    /// that may reference descriptor types.
232    pub fn new(
233        files: &'a [FileDescriptorProto],
234        config: &'a CodeGenConfig,
235        effective_extern_paths: &[(String, String)],
236    ) -> Self {
237        Self::with_extern_resolution(files, config, effective_extern_paths, &[])
238    }
239
240    /// Build a context with both package-level and file-level extern
241    /// mappings.
242    ///
243    /// `file_extern_paths` maps a `.proto` file's full path (as reported in
244    /// `FileDescriptorProto.name`, e.g. `"google/protobuf/descriptor.proto"`)
245    /// to a Rust module root. It takes priority over a package-level
246    /// `effective_extern_paths` match, which lets two files in the same proto
247    /// package (`google.protobuf`) resolve to different external crates —
248    /// `descriptor.proto` types to `buffa-descriptor`, every other
249    /// `google.protobuf` WKT to `buffa-types`. Without this split a single
250    /// package-keyed mapping would route everything to one crate or the
251    /// other.
252    ///
253    /// Per-type resolution priority (issue #111), most specific first: an
254    /// **exact** `extern_path` entry for a type's FQN, then the **file-level**
255    /// mapping above, then a **package/prefix** `extern_path` match, then the
256    /// **local** package path. See [`resolve_type_path`].
257    ///
258    /// File-level mappings are an internal mechanism used for the
259    /// auto-injected descriptor-types routing. They are not part of the
260    /// public `CodeGenConfig` API; user-facing `extern_path` entries are keyed
261    /// by proto package *or* type FQN.
262    pub(crate) fn with_extern_resolution(
263        files: &'a [FileDescriptorProto],
264        config: &'a CodeGenConfig,
265        effective_extern_paths: &[(String, String)],
266        file_extern_paths: &[(String, String)],
267    ) -> Self {
268        let mut type_map = HashMap::new();
269        let mut package_of = HashMap::new();
270        let mut enum_closedness = HashMap::new();
271        let mut comment_map = HashMap::new();
272        let mut nested_module_names = HashMap::new();
273        let msg_index = crate::oneof::message_index(files);
274        let unboxed_oneof_variants = crate::oneof::resolve_unboxed_variants(
275            &msg_index,
276            &config.unboxed_oneof_fields,
277            &config.pointer_fields,
278        );
279        let inlined_message_fields = crate::oneof::resolve_inlined_fields(
280            &msg_index,
281            &config.unboxed_oneof_fields,
282            &config.pointer_fields,
283        );
284
285        // Pre-pass: collect every package and top-level message name in the
286        // descriptor set so nested-types module deconfliction (issue #135) is
287        // identical whether the package is generated locally or referenced via
288        // `extern_path`. Skipping extern packages here made cross-crate refs
289        // use plain `snake_case` (e.g. `money::Currency`) while the owning
290        // crate emits deconflicted modules (e.g. `money_::Currency`).
291        let mut all_packages: HashSet<String> = HashSet::new();
292        let mut pkg_message_names: HashMap<String, Vec<String>> = HashMap::new();
293        for file in files {
294            let package = file.package.as_deref().unwrap_or("");
295            all_packages.insert(package.to_string());
296            for msg in &file.message_type {
297                if let Some(name) = &msg.name {
298                    // A per-type `extern_path` override (issue #111) makes the
299                    // message extern: it emits no local module, so it must not
300                    // reserve a module name in sub-package deconfliction (#135).
301                    let fqn = if package.is_empty() {
302                        format!(".{name}")
303                    } else {
304                        format!(".{package}.{name}")
305                    };
306                    if effective_extern_paths
307                        .iter()
308                        .any(|(proto, _)| proto == &fqn)
309                    {
310                        continue;
311                    }
312                    pkg_message_names
313                        .entry(package.to_string())
314                        .or_default()
315                        .push(name.clone());
316                }
317            }
318        }
319
320        // Resolve the deconflicted nested-types module name for every top-level
321        // message, batched per package so racing deconflictions stay distinct.
322        // Each package is an independent scope, so the populated map is the same
323        // regardless of `pkg_message_names` iteration order.
324        for (package, names) in &pkg_message_names {
325            let children = child_package_segments(package, &all_packages);
326            let modules = deconflict_package_modules(names, &children);
327            for (name, module) in names.iter().zip(modules) {
328                let fqn = if package.is_empty() {
329                    format!(".{name}")
330                } else {
331                    format!(".{package}.{name}")
332                };
333                nested_module_names.insert(fqn, module);
334            }
335        }
336
337        for file in files {
338            comment_map.extend(crate::comments::fqn_comments(file));
339            let package = file.package.as_deref().unwrap_or("");
340            let file_features = features::for_file(file);
341            let proto_prefix = if package.is_empty() {
342                String::from(".")
343            } else {
344                format!(".{}.", package)
345            };
346
347            // The file-level extern root (the internal descriptor.proto →
348            // buffa-descriptor split) and the local package module. Per-type
349            // resolution (issue #111) layers exact/longest-prefix `extern_path`
350            // matching on top, via `resolve_type_path`, which preserves the
351            // historic priority: per-type exact → file-level → package prefix →
352            // local. The file-level check still outranks a package prefix so two
353            // files in the same proto package can route to different crates
354            // (descriptor.proto → buffa-descriptor, timestamp.proto →
355            // buffa-types).
356            let file_root = file
357                .name
358                .as_deref()
359                .and_then(|n| resolve_file_extern(n, file_extern_paths));
360            let local_module = package.replace('.', "::");
361
362            // Register top-level messages
363            for msg in &file.message_type {
364                if let Some(name) = &msg.name {
365                    let fqn = format!("{}{}", proto_prefix, name);
366                    let (rust_path, is_extern) = resolve_type_path(
367                        &fqn,
368                        name,
369                        file_root,
370                        &local_module,
371                        effective_extern_paths,
372                        &config.type_name_prefix,
373                    );
374
375                    // The module the message's nested types live in. For a local
376                    // message it is `<package>::<module>`, where the module name
377                    // is deconflicted against sub-package modules (issue #135),
378                    // precomputed above and looked up here so emission and
379                    // references share the same value. For an extern/overridden
380                    // message no local module is emitted, so the nested module is
381                    // the resolved path's parent plus the plain `snake_case` name.
382                    let parent_mod = if is_extern {
383                        let snake = nested_module_names
384                            .get(&fqn)
385                            .cloned()
386                            .unwrap_or_else(|| to_snake_case(name));
387                        match rust_path.rsplit_once("::") {
388                            Some((parent, _)) => format!("{parent}::{snake}"),
389                            None => snake,
390                        }
391                    } else {
392                        let snake = nested_module_names
393                            .get(&fqn)
394                            .cloned()
395                            .unwrap_or_else(|| to_snake_case(name));
396                        join_mod(&local_module, &snake)
397                    };
398
399                    type_map.insert(fqn.clone(), rust_path);
400                    package_of.insert(fqn.clone(), package.to_string());
401                    register_nested_types(
402                        &mut type_map,
403                        &mut package_of,
404                        NestedRegistrationCtx {
405                            package,
406                            extern_paths: effective_extern_paths,
407                            type_name_prefix: &config.type_name_prefix,
408                        },
409                        &fqn,
410                        &parent_mod,
411                        msg,
412                    );
413                    register_nested_enum_closedness(
414                        &mut enum_closedness,
415                        &fqn,
416                        &file_features,
417                        msg,
418                    );
419                }
420            }
421
422            // Register top-level enums
423            for enum_type in &file.enum_type {
424                if let Some(name) = &enum_type.name {
425                    let fqn = format!("{}{}", proto_prefix, name);
426                    let (rust_path, _) = resolve_type_path(
427                        &fqn,
428                        name,
429                        file_root,
430                        &local_module,
431                        effective_extern_paths,
432                        &config.type_name_prefix,
433                    );
434                    type_map.insert(fqn.clone(), rust_path);
435                    package_of.insert(fqn.clone(), package.to_string());
436                    register_enum_closedness(&mut enum_closedness, &fqn, &file_features, enum_type);
437                }
438            }
439        }
440
441        // Plan the idiomatic field-name conversion up front so every
442        // emission site resolves the same Rust name for a field. The plan's
443        // collision warnings are seeded into the sink now and drained with
444        // the rest after generation.
445        let (field_renames, oneof_keep_verbatim, plan_warnings) = if config.idiomatic_field_names {
446            let plan = crate::field_names::plan_field_names(files);
447            (plan.field_renames, plan.oneof_keep_verbatim, plan.warnings)
448        } else {
449            (HashMap::new(), HashSet::new(), Vec::new())
450        };
451
452        Self {
453            files,
454            config,
455            type_map,
456            package_of,
457            enum_closedness,
458            // Only consulted by opened-enum default handling (a bare open
459            // enum can otherwise never have a non-zero first value), so the
460            // walk is skipped entirely for the default configuration.
461            enum_first_value: if config.has_enum_type_overrides() {
462                collect_enum_first_values(files)
463            } else {
464                HashMap::new()
465            },
466            comment_map,
467            nested_module_names,
468            unboxed_oneof_variants,
469            inlined_message_fields,
470            field_renames,
471            oneof_keep_verbatim,
472            warnings: std::cell::RefCell::new(plan_warnings),
473            imports: std::cell::RefCell::new(crate::imports::ImportsPhase::Off),
474        }
475    }
476
477    /// The Rust source name for a proto field (pre keyword escaping).
478    ///
479    /// With `idiomatic_field_names` off this is the proto name verbatim.
480    /// With it on, the name is snake_case-converted, unless the collision
481    /// plan recorded an exception for `(name, number)` (see
482    /// [`crate::field_names`]).
483    pub(crate) fn field_rust_name<'n>(&'n self, name: &'n str, number: i32) -> Cow<'n, str> {
484        if !self.config.idiomatic_field_names {
485            return Cow::Borrowed(name);
486        }
487        if !self.field_renames.is_empty() {
488            if let Some(renamed) = self.field_renames.get(&(name.to_string(), number)) {
489                return Cow::Borrowed(renamed.as_str());
490            }
491        }
492        let converted = crate::field_names::idiomatic_snake_case(name);
493        if converted == name {
494            Cow::Borrowed(name)
495        } else {
496            Cow::Owned(converted)
497        }
498    }
499
500    /// The Rust field identifier for a proto field:
501    /// [`field_rust_name`](Self::field_rust_name) plus keyword escaping.
502    pub(crate) fn field_ident(&self, name: &str, number: i32) -> proc_macro2::Ident {
503        crate::idents::make_field_ident(&self.field_rust_name(name, number))
504    }
505
506    /// The Rust source name for a oneof (pre keyword escaping).
507    ///
508    /// Mirrors [`field_rust_name`](Self::field_rust_name); a oneof whose
509    /// conversion collided keeps its verbatim proto name.
510    pub(crate) fn oneof_rust_name<'n>(&'n self, name: &'n str) -> Cow<'n, str> {
511        if !self.config.idiomatic_field_names || self.oneof_keep_verbatim.contains(name) {
512            return Cow::Borrowed(name);
513        }
514        let converted = crate::field_names::idiomatic_snake_case(name);
515        if converted == name {
516            Cow::Borrowed(name)
517        } else {
518            Cow::Owned(converted)
519        }
520    }
521
522    /// The Rust field identifier for a oneof:
523    /// [`oneof_rust_name`](Self::oneof_rust_name) plus keyword escaping.
524    pub(crate) fn oneof_ident(&self, name: &str) -> proc_macro2::Ident {
525        crate::idents::make_field_ident(&self.oneof_rust_name(name))
526    }
527
528    /// Doc note for a field whose Rust name was *adjusted* by the
529    /// `idiomatic_field_names` collision plan (an `_f<number>` suffix or a
530    /// verbatim fallback). `None` for the plain conversion — there the
531    /// `Field N: `name`` doc tag already discloses the proto name.
532    pub(crate) fn field_rename_note(&self, name: &str, number: i32) -> Option<String> {
533        if !self.config.idiomatic_field_names || self.field_renames.is_empty() {
534            return None;
535        }
536        let resolved = self.field_renames.get(&(name.to_string(), number))?;
537        Some(format!(
538            " Note: the snake_case conversion of `{name}` collides with another \
539             member of this message; the Rust name was adjusted to `{resolved}` \
540             (`_f<n>` suffixes carry the field number)."
541        ))
542    }
543
544    /// `#[allow(non_snake_case)]` when any of `msg`'s emitted member names —
545    /// non-oneof-member fields and real oneofs, after `idiomatic_field_names`
546    /// resolution — contains an uppercase character (the rustc lint's
547    /// trigger; proto identifiers are ASCII). Empty for conforming messages,
548    /// so their generated output is unchanged.
549    ///
550    /// Applied at the struct and impl level (struct declarations and the
551    /// setter / `Deserialize` / `has_*` / owned-view-wrapper impls) rather
552    /// than per member, so it covers the pub fields *and* every method and
553    /// local whose name derives from them. Detection is independent of
554    /// `idiomatic_field_names`: a verbatim camelCase proto compiled with the
555    /// option off gets the same scoped allows, keeping consumer crates
556    /// warning-free either way.
557    pub(crate) fn message_non_snake_attr(&self, msg: &DescriptorProto) -> proc_macro2::TokenStream {
558        let non_snake = |s: &str| s.contains(|c: char| c.is_ascii_uppercase());
559        let mut real_oneofs: HashSet<i32> = HashSet::new();
560        let mut found = false;
561        for field in &msg.field {
562            let Some(name) = field.name.as_deref() else {
563                continue;
564            };
565            // Mirrors `impl_message::is_real_oneof_member` (not imported to
566            // keep context.rs free of emission-module dependencies).
567            if field.oneof_index.is_some() && !field.proto3_optional.unwrap_or(false) {
568                if let Some(idx) = field.oneof_index {
569                    real_oneofs.insert(idx);
570                }
571                continue;
572            }
573            if non_snake(&self.field_rust_name(name, field.number.unwrap_or(0))) {
574                found = true;
575                break;
576            }
577        }
578        if !found {
579            for (idx, oneof) in msg.oneof_decl.iter().enumerate() {
580                let Some(name) = oneof.name.as_deref() else {
581                    continue;
582                };
583                if !real_oneofs.contains(&i32::try_from(idx).unwrap_or(i32::MAX)) {
584                    continue;
585                }
586                if non_snake(&self.oneof_rust_name(name)) {
587                    found = true;
588                    break;
589                }
590            }
591        }
592        if found {
593            quote::quote! { #[allow(non_snake_case)] }
594        } else {
595            proc_macro2::TokenStream::new()
596        }
597    }
598
599    /// Record a non-fatal diagnostic to surface as a build warning.
600    pub(crate) fn warn(&self, warning: crate::CodeGenWarning) {
601        self.warnings.borrow_mut().push(warning);
602    }
603
604    /// Drain the diagnostics accumulated during generation.
605    ///
606    /// `pub(crate)` so it can only be called from [`generate_with_diagnostics`]
607    /// after all packages are generated — draining mid-flight would truncate the
608    /// diagnostic stream.
609    pub(crate) fn take_warnings(&self) -> Vec<crate::CodeGenWarning> {
610        self.warnings.take()
611    }
612
613    /// Truncate the warning sink back to `len` entries.
614    ///
615    /// Used by the `idiomatic_imports` collection pass, whose dry-run
616    /// generation would otherwise record every diagnostic twice.
617    pub(crate) fn truncate_warnings(&self, len: usize) {
618        self.warnings.borrow_mut().truncate(len);
619    }
620
621    /// The number of warnings recorded so far (a mark for
622    /// [`truncate_warnings`](Self::truncate_warnings)).
623    pub(crate) fn warnings_len(&self) -> usize {
624        self.warnings.borrow().len()
625    }
626
627    // ── Package-root import registry (idiomatic_imports) ────────────────
628
629    /// Enter the collection phase: subsequent package-root path
630    /// resolutions are recorded instead of shortened.
631    pub(crate) fn imports_begin_collecting(&self) {
632        *self.imports.borrow_mut() =
633            crate::imports::ImportsPhase::Collecting(std::collections::BTreeSet::new());
634    }
635
636    /// Leave the collection phase, returning the recorded paths.
637    pub(crate) fn imports_take_collected(&self) -> std::collections::BTreeSet<String> {
638        match self.imports.replace(crate::imports::ImportsPhase::Off) {
639            crate::imports::ImportsPhase::Collecting(set) => set,
640            _ => std::collections::BTreeSet::new(),
641        }
642    }
643
644    /// Enter the resolution phase with assigned bindings.
645    pub(crate) fn imports_set_resolving(&self, imports: crate::imports::RootImports) {
646        *self.imports.borrow_mut() = crate::imports::ImportsPhase::Resolving(imports);
647    }
648
649    /// Reset to [`ImportsPhase::Off`] after a package is generated, so
650    /// state never leaks into the next package.
651    ///
652    /// [`ImportsPhase::Off`]: crate::imports::ImportsPhase::Off
653    pub(crate) fn imports_reset(&self) {
654        *self.imports.borrow_mut() = crate::imports::ImportsPhase::Off;
655    }
656
657    /// The `use` block backing the current package's assigned short names
658    /// (empty unless in the resolution phase).
659    pub(crate) fn imports_use_block(&self) -> proc_macro2::TokenStream {
660        match &*self.imports.borrow() {
661            crate::imports::ImportsPhase::Resolving(imports) => imports.use_items(),
662            _ => proc_macro2::TokenStream::new(),
663        }
664    }
665
666    /// Route a package-root type path through the import registry.
667    ///
668    /// `nesting` is the emitting scope's module depth below the package
669    /// root; only depth-0 emissions land in the package-root scope where
670    /// the `use` block is visible, so anything deeper passes through
671    /// unchanged — as does everything when the registry is off.
672    fn root_path(&self, path: String, nesting: usize) -> String {
673        if nesting != 0 {
674            return path;
675        }
676        match &mut *self.imports.borrow_mut() {
677            crate::imports::ImportsPhase::Off => path,
678            crate::imports::ImportsPhase::Collecting(set) => {
679                if crate::imports::shortenable(&path) {
680                    set.insert(path.clone());
681                }
682                path
683            }
684            crate::imports::ImportsPhase::Resolving(imports) => match imports.resolve(&path) {
685                Some(short) => short.to_string(),
686                None => path,
687            },
688        }
689    }
690
691    /// Route a runtime/prelude type's canonical qualified path through the
692    /// import registry, returning the tokens to emit.
693    ///
694    /// Same depth-0 gating as [`root_path`](Self::root_path); `canonical`
695    /// must be one of the `RUNTIME_IMPORTS` paths in `imports.rs`.
696    pub(crate) fn root_runtime_path(
697        &self,
698        canonical: &str,
699        nesting: usize,
700    ) -> proc_macro2::TokenStream {
701        crate::idents::rust_path_to_tokens(&self.root_path(canonical.to_string(), nesting))
702    }
703
704    /// The nested-types module name for a top-level message, deconflicted
705    /// against sub-package modules (issue #135).
706    ///
707    /// `package` is the proto package (empty for none), `name` the message's
708    /// proto name. Returns the recorded deconflicted name (e.g. `oof_` when
709    /// `message Oof` collides with `package <pkg>.oof`), or `snake_case(name)`
710    /// when no override was recorded. Both emission and reference resolution go
711    /// through the same recorded value, so they always agree.
712    pub fn nested_module_name(&self, package: &str, name: &str) -> String {
713        let fqn = if package.is_empty() {
714            format!(".{name}")
715        } else {
716            format!(".{package}.{name}")
717        };
718        self.nested_module_names
719            .get(&fqn)
720            .cloned()
721            .unwrap_or_else(|| to_snake_case(name))
722    }
723
724    /// Build a context matching what [`generate()`](crate::generate) uses
725    /// internally.
726    ///
727    /// Computes effective extern paths (user-provided + auto-injected WKT
728    /// mapping to `buffa-types` + auto-injected `descriptor.proto` /
729    /// `compiler/plugin.proto` file-level mapping to `buffa-descriptor`) and
730    /// builds the type map from them.
731    ///
732    /// Convenience for downstream generators (e.g. `connectrpc-codegen`)
733    /// that emit code alongside buffa's message types and need identical
734    /// type-path resolution. Using this instead of [`new()`](Self::new) +
735    /// manual extern-path computation ensures zero drift with buffa's own
736    /// generation.
737    pub fn for_generate(
738        files: &'a [FileDescriptorProto],
739        files_to_generate: &[String],
740        config: &'a CodeGenConfig,
741    ) -> Self {
742        let paths = crate::effective_extern_paths(files, files_to_generate, config);
743        let file_paths = crate::effective_file_extern_paths(files_to_generate, config);
744        Self::with_extern_resolution(files, config, &paths, &file_paths)
745    }
746
747    /// Look up the Rust type path for a fully-qualified protobuf type name.
748    pub fn rust_type(&self, proto_fqn: &str) -> Option<&str> {
749        self.type_map.get(proto_fqn).map(|s| s.as_str())
750    }
751
752    /// Look up the source comment for a protobuf element by FQN.
753    ///
754    /// `fqn` uses the same dotted form as `proto_fqn` throughout codegen
755    /// (no leading dot). For sub-elements, append the element name:
756    /// - Message: `"pkg.Message"`
757    /// - Field: `"pkg.Message.field_name"`
758    /// - Enum value: `"pkg.Enum.VALUE_NAME"`
759    /// - Oneof: `"pkg.Message.oneof_name"`
760    pub fn comment(&self, fqn: &str) -> Option<&str> {
761        self.comment_map.get(fqn).map(|s| s.as_str())
762    }
763
764    /// Look up whether an enum (by fully-qualified proto name) is closed.
765    ///
766    /// Returns `None` if the enum is not in this compilation set (e.g., an
767    /// extern_path type), in which case callers should fall back to the
768    /// referencing field's feature chain (correct for proto2/proto3 where
769    /// `enum_type` is file-level anyway).
770    pub fn is_enum_closed(&self, proto_fqn: &str) -> Option<bool> {
771        self.enum_closedness.get(proto_fqn).copied()
772    }
773
774    /// Look up an enum's first declared value number — its implicit default
775    /// per spec. `None` for enums not in this compilation set (extern_path).
776    pub(crate) fn enum_first_value(&self, proto_fqn: &str) -> Option<i32> {
777        self.enum_first_value.get(proto_fqn).copied()
778    }
779
780    /// Look up the Rust type path relative to the current code generation
781    /// scope.
782    ///
783    /// `current_package` is the proto package (e.g., `"google.protobuf"`).
784    /// `nesting` is the number of message module levels the generated code
785    /// sits inside (0 for struct fields and impls at the package level,
786    /// 1 for oneof enums inside a message module, etc.).
787    ///
788    /// - **Same package**: strips the package prefix and prepends `super::`
789    ///   for each nesting level.
790    /// - **Cross package (local)**: navigates via `super::` to the common
791    ///   ancestor, then descends into the target package. This works
792    ///   regardless of where the module tree is placed in the user's crate.
793    /// - **Cross package (extern)**: returns the absolute extern path as-is.
794    pub fn rust_type_relative(
795        &self,
796        proto_fqn: &str,
797        current_package: &str,
798        nesting: usize,
799    ) -> Option<String> {
800        let full_path = self.type_map.get(proto_fqn)?;
801
802        // Extern types use absolute paths (starting with `::` or `crate::`)
803        // and need no relative resolution — they work from any module position.
804        if full_path.starts_with("::") || full_path.starts_with("crate::") {
805            return Some(self.root_path(full_path.clone(), nesting));
806        }
807
808        let target_package = self
809            .package_of
810            .get(proto_fqn)
811            .map(|s| s.as_str())
812            .unwrap_or("");
813
814        // Extract the type's path within its package (everything after the
815        // package module prefix).
816        let target_rust_module = target_package.replace('.', "::");
817        let type_suffix = if target_rust_module.is_empty() {
818            full_path.as_str()
819        } else {
820            full_path
821                .strip_prefix(&format!("{}::", target_rust_module))
822                .unwrap_or(full_path)
823        };
824
825        if current_package == target_package {
826            // Same package — just the type suffix, with super:: for nesting.
827            if nesting == 0 {
828                return Some(type_suffix.to_string());
829            }
830            let supers = (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::");
831            return Some(format!("{}::{}", supers, type_suffix));
832        }
833
834        // Cross-package local type: compute a super::-based relative path.
835        let current_parts: Vec<&str> = if current_package.is_empty() {
836            vec![]
837        } else {
838            current_package.split('.').collect()
839        };
840        let target_parts: Vec<&str> = if target_package.is_empty() {
841            vec![]
842        } else {
843            target_package.split('.').collect()
844        };
845
846        // Find the length of the common package prefix.
847        let common_len = current_parts
848            .iter()
849            .zip(&target_parts)
850            .take_while(|(a, b)| a == b)
851            .count();
852
853        // Navigate up: one super:: per remaining current package segment,
854        // plus one per nesting level (message module depth).
855        let up_count = (current_parts.len() - common_len) + nesting;
856
857        // Navigate down: target package segments beyond the common prefix.
858        let down_parts = &target_parts[common_len..];
859
860        let mut segments: Vec<&str> = vec!["super"; up_count];
861        segments.extend_from_slice(down_parts);
862
863        // Append the type's within-package path.
864        let mut result = segments.join("::");
865        if !result.is_empty() {
866            result.push_str("::");
867        }
868        result.push_str(type_suffix);
869
870        Some(self.root_path(result, nesting))
871    }
872
873    /// Like [`rust_type_relative`](Self::rust_type_relative) but returns the
874    /// path split at the target-package boundary.
875    ///
876    /// Ancillary kinds (views, oneof enums) live in the `__buffa::<kind>::`
877    /// sub-tree of each package; callers compose the final path as
878    /// `to_package + "::__buffa::" + <kind> + "::" + within_package`.
879    ///
880    /// `nesting` is the **total** module depth of the caller's emission
881    /// scope below the current package root — i.e. message-nesting plus any
882    /// `__buffa::<kind>::` levels the caller is already inside (0 for owned
883    /// types, +2 for `__buffa::view::`, +3 for `__buffa::view::oneof::`).
884    pub fn rust_type_relative_split(
885        &self,
886        proto_fqn: &str,
887        current_package: &str,
888        nesting: usize,
889    ) -> Option<SplitPath> {
890        let full_path = self.type_map.get(proto_fqn)?;
891
892        let target_package = self
893            .package_of
894            .get(proto_fqn)
895            .map(|s| s.as_str())
896            .unwrap_or("");
897
898        // Compute the type's path within its package (everything after the
899        // package module prefix). For extern types the prefix is the
900        // configured rust_module (e.g. `::buffa_types::google::protobuf`),
901        // not the bare dotted package, so derive it the same way `new()`
902        // populated the map.
903        let target_rust_module = if full_path.starts_with("::") || full_path.starts_with("crate::")
904        {
905            // Reconstruct the extern module prefix by stripping the
906            // within-package suffix length. We know the proto FQN's
907            // within-package portion (FQN minus package), so the full_path's
908            // last N segments correspond to it.
909            //
910            // Simpler: re-derive via `resolve_extern_prefix` would need the
911            // original extern_paths list. Instead, compute within-package
912            // from the proto FQN (which we know) and slice full_path.
913            let fqn_no_dot = proto_fqn.strip_prefix('.').unwrap_or(proto_fqn);
914            let within_proto = if target_package.is_empty() {
915                fqn_no_dot
916            } else {
917                fqn_no_dot
918                    .strip_prefix(target_package)
919                    .and_then(|s| s.strip_prefix('.'))
920                    .unwrap_or(fqn_no_dot)
921            };
922            // within_proto is dotted (e.g. "Outer.Inner"); within full_path
923            // it's `outer::Inner` (snake_case modules + final PascalCase).
924            // Count the segments and strip that many from full_path to recover
925            // the module the type lives in.
926            //
927            // For paths buffa builds itself (`<rust_module>::<within>`) and for
928            // per-type `extern_path` overrides whose target mirrors the proto
929            // nesting (the sensible case — e.g. mapping to another
930            // buffa-generated crate), `full_segs.len() >= within_segs`. A
931            // pathological override that maps a deeply-nested type to a shorter
932            // Rust path can't have a matching `__buffa::` view/oneof tree
933            // anyway, so we clamp with `saturating_sub` rather than panic and
934            // let the (unresolvable) reference surface as a normal compile error.
935            let within_segs = within_proto.split('.').count();
936            let full_segs: Vec<&str> = full_path.split("::").collect();
937            let cut = full_segs.len().saturating_sub(within_segs);
938            full_segs[..cut].join("::")
939        } else {
940            target_package.replace('.', "::")
941        };
942
943        let type_suffix = if target_rust_module.is_empty() {
944            full_path.as_str()
945        } else {
946            full_path
947                .strip_prefix(&format!("{}::", target_rust_module))
948                .unwrap_or(full_path)
949        };
950
951        // Extern: absolute path; nesting irrelevant.
952        if full_path.starts_with("::") || full_path.starts_with("crate::") {
953            return Some(SplitPath {
954                to_package: target_rust_module,
955                within_package: type_suffix.to_string(),
956                is_extern: true,
957            });
958        }
959
960        if current_package == target_package {
961            let to_package = if nesting == 0 {
962                String::new()
963            } else {
964                (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::")
965            };
966            return Some(SplitPath {
967                to_package,
968                within_package: type_suffix.to_string(),
969                is_extern: false,
970            });
971        }
972
973        // Cross-package local.
974        let current_parts: Vec<&str> = if current_package.is_empty() {
975            vec![]
976        } else {
977            current_package.split('.').collect()
978        };
979        let target_parts: Vec<&str> = if target_package.is_empty() {
980            vec![]
981        } else {
982            target_package.split('.').collect()
983        };
984        let common_len = current_parts
985            .iter()
986            .zip(&target_parts)
987            .take_while(|(a, b)| a == b)
988            .count();
989        let up_count = (current_parts.len() - common_len) + nesting;
990        let down_parts = &target_parts[common_len..];
991
992        let mut segments: Vec<&str> = vec!["super"; up_count];
993        segments.extend_from_slice(down_parts);
994
995        Some(SplitPath {
996            to_package: segments.join("::"),
997            within_package: type_suffix.to_string(),
998            is_extern: false,
999        })
1000    }
1001
1002    /// Collect custom attributes matching a fully-qualified proto path.
1003    ///
1004    /// Returns a `TokenStream` of all `#[...]` attributes whose path prefix
1005    /// matches `fqn`. Each attribute string is parsed via `syn::parse_str`
1006    /// so the caller can interpolate directly into `quote!`.
1007    ///
1008    /// `fqn` uses dotted form without a leading dot (e.g., `"my.pkg.MyMessage"`).
1009    ///
1010    /// # Errors
1011    ///
1012    /// Returns `CodeGenError::InvalidCustomAttribute` if any matching attribute
1013    /// string fails to parse as a valid Rust attribute.
1014    pub(crate) fn matching_attributes(
1015        attrs: &[(String, String)],
1016        fqn: &str,
1017    ) -> Result<proc_macro2::TokenStream, crate::CodeGenError> {
1018        if attrs.is_empty() {
1019            return Ok(proc_macro2::TokenStream::new());
1020        }
1021        let fqn_dotted = format!(".{fqn}");
1022        let mut tokens = proc_macro2::TokenStream::new();
1023        for (prefix, attr_str) in attrs {
1024            if matches_proto_prefix(prefix, &fqn_dotted) {
1025                let parsed =
1026                    syn::parse_str::<proc_macro2::TokenStream>(attr_str).map_err(|err| {
1027                        crate::CodeGenError::InvalidCustomAttribute {
1028                            path: prefix.clone(),
1029                            attribute: attr_str.clone(),
1030                            detail: err.to_string(),
1031                        }
1032                    })?;
1033                tokens.extend(parsed);
1034            }
1035        }
1036        Ok(tokens)
1037    }
1038
1039    /// Resolve the [`BytesRepr`](crate::BytesRepr) for a `bytes` field at the
1040    /// given proto path.
1041    ///
1042    /// `field_fqn` is the fully-qualified proto field path, e.g.,
1043    /// `".my.pkg.MyMessage.data"`. Rules in `config.bytes_fields` are matched
1044    /// using proto-segment-aware prefix matching (`"."` matches all,
1045    /// `".my.pkg"` matches `".my.pkg.Msg.data"` but not `".my.pkgs.X.data"`);
1046    /// the **last** matching rule wins, letting a specific override follow a
1047    /// broad default. Fields matching no rule use
1048    /// [`BytesRepr::Vec`](crate::BytesRepr::Vec).
1049    pub fn bytes_repr(&self, field_fqn: &str) -> crate::BytesRepr {
1050        self.config
1051            .bytes_fields
1052            .iter()
1053            .rev()
1054            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
1055            .map_or(crate::BytesRepr::default(), |(_, repr)| repr.clone())
1056    }
1057
1058    /// Check whether a message-typed oneof variant at the given proto path is
1059    /// stored inline (opted out of `Box` wrapping).
1060    ///
1061    /// `variant_fqn` is the fully-qualified variant path, e.g.
1062    /// `".my.pkg.MyMessage.body.small"`. This consults the set resolved at
1063    /// context construction by the internal `resolve_unboxed_variants` pass,
1064    /// not the raw config rules: recursive variants matched only by a prefix
1065    /// rule are excluded there (they stay boxed), so every codegen site that
1066    /// asks agrees with the emitted enum declaration.
1067    pub fn oneof_unboxed(&self, variant_fqn: &str) -> bool {
1068        self.unboxed_oneof_variants.contains(variant_fqn)
1069    }
1070
1071    /// Resolve the [`StringRepr`](crate::StringRepr) for a `string` field at the
1072    /// given proto path.
1073    ///
1074    /// `field_fqn` is the fully-qualified proto field path, e.g.
1075    /// `".my.pkg.MyMessage.name"`. Rules in `config.string_fields` are matched
1076    /// with the same proto-segment-aware prefix logic as
1077    /// [`bytes_repr`](Self::bytes_repr); the **last** matching rule wins,
1078    /// letting a specific override follow a broad default. Fields matching no
1079    /// rule use [`StringRepr::String`](crate::StringRepr::String).
1080    pub fn string_repr(&self, field_fqn: &str) -> crate::StringRepr {
1081        self.config
1082            .string_fields
1083            .iter()
1084            .rev()
1085            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
1086            .map_or(crate::StringRepr::default(), |(_, repr)| repr.clone())
1087    }
1088
1089    /// Resolve the [`MapRepr`](crate::MapRepr) for a `map` field at the given
1090    /// proto path.
1091    ///
1092    /// `field_fqn` is the fully-qualified proto field path, e.g.
1093    /// `".my.pkg.MyMessage.entries"`. Rules in `config.map_fields` are matched
1094    /// with the same proto-segment-aware prefix logic as
1095    /// [`string_repr`](Self::string_repr); the **last** matching rule wins,
1096    /// letting a specific override follow a broad default. Fields matching no
1097    /// rule use [`MapRepr::HashMap`](crate::MapRepr::HashMap).
1098    pub fn map_repr(&self, field_fqn: &str) -> crate::MapRepr {
1099        self.config
1100            .map_fields
1101            .iter()
1102            .rev()
1103            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
1104            .map_or(crate::MapRepr::default(), |(_, repr)| repr.clone())
1105    }
1106
1107    /// Resolve the [`PointerRepr`](crate::PointerRepr) for a singular message
1108    /// field at the given proto path. Last matching rule wins (proto-segment
1109    /// prefix match); fields matching no rule use
1110    /// [`PointerRepr::Box`](crate::PointerRepr::Box).
1111    ///
1112    /// `Inline` is recursion-aware: a path whose raw rule resolves to
1113    /// [`PointerRepr::Inline`](crate::PointerRepr::Inline) but is absent from
1114    /// the precomputed `inlined_message_fields` set
1115    /// (a recursive singular field, or a non-singular path such as a oneof
1116    /// variant) is demoted to `Box` so the generated type stays sized.
1117    pub fn pointer_repr(&self, field_fqn: &str) -> crate::PointerRepr {
1118        let raw = self
1119            .config
1120            .pointer_fields
1121            .iter()
1122            .rev()
1123            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
1124            .map_or(crate::PointerRepr::default(), |(_, repr)| repr.clone());
1125        if raw == crate::PointerRepr::Inline && !self.inlined_message_fields.contains(field_fqn) {
1126            crate::PointerRepr::Box
1127        } else {
1128            raw
1129        }
1130    }
1131
1132    /// Resolve the [`RepeatedRepr`](crate::RepeatedRepr) for a `repeated` field
1133    /// at the given proto path.
1134    ///
1135    /// `field_fqn` is the fully-qualified proto field path, e.g.
1136    /// `".my.pkg.MyMessage.items"`. Rules in `config.repeated_fields` are matched
1137    /// with the same proto-segment-aware prefix logic as
1138    /// [`string_repr`](Self::string_repr); the **last** matching rule wins,
1139    /// letting a specific override follow a broad default. Fields matching no
1140    /// rule use [`RepeatedRepr::Vec`](crate::RepeatedRepr::Vec).
1141    pub fn repeated_repr(&self, field_fqn: &str) -> crate::RepeatedRepr {
1142        self.config
1143            .repeated_fields
1144            .iter()
1145            .rev()
1146            .find(|(prefix, _)| matches_proto_prefix(prefix, field_fqn))
1147            .map_or(crate::RepeatedRepr::default(), |(_, repr)| repr.clone())
1148    }
1149}
1150
1151/// Scope-local context for code generation within a message.
1152///
1153/// Bundles the parameters that are constant within a single message's code
1154/// generation scope and change only when recursing into nested messages.
1155/// Threading this struct instead of five individual parameters keeps function
1156/// signatures short and makes adding new scope-level state a one-field change.
1157#[derive(Clone, Copy)]
1158pub(crate) struct MessageScope<'a> {
1159    /// Global codegen context (descriptors, type map, config).
1160    pub ctx: &'a CodeGenContext<'a>,
1161    /// Proto package of the file being generated (e.g. `"google.protobuf"`).
1162    pub current_package: &'a str,
1163    /// Fully-qualified proto name of the current message
1164    /// (e.g. `"google.protobuf.Timestamp"`, `"pkg.Outer.Inner"`).
1165    pub proto_fqn: &'a str,
1166    /// Resolved edition features for this message scope.
1167    pub features: &'a ResolvedFeatures,
1168    /// Module nesting depth — number of `pub mod` levels the generated code
1169    /// sits inside.  Controls the count of `super::` prefixes in type
1170    /// references via [`CodeGenContext::rust_type_relative`].
1171    pub nesting: usize,
1172}
1173
1174impl<'a> MessageScope<'a> {
1175    /// Create a child scope for a nested message (increments nesting by 1).
1176    pub fn nested(&self, proto_fqn: &'a str, features: &'a ResolvedFeatures) -> MessageScope<'a> {
1177        MessageScope {
1178            ctx: self.ctx,
1179            current_package: self.current_package,
1180            proto_fqn,
1181            features,
1182            nesting: self.nesting + 1,
1183        }
1184    }
1185}
1186
1187/// Kind of ancillary tree under the [`SENTINEL_MOD`] module.
1188///
1189/// `path_segments()` returns the module path *inside* `__buffa::` (not
1190/// including the sentinel itself).
1191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1192pub(crate) enum AncillaryKind {
1193    /// `__buffa::oneof::<msg_path>::` — owned oneof enums.
1194    Oneof,
1195    /// `__buffa::view::<msg_path>::` — message view structs.
1196    View,
1197    /// `__buffa::view::oneof::<msg_path>::` — view oneof enums.
1198    ViewOneof,
1199    /// `__buffa::lazy_view::<msg_path>::` — lazy view structs.
1200    LazyView,
1201}
1202
1203impl AncillaryKind {
1204    fn path_segments(self) -> &'static [&'static str] {
1205        match self {
1206            Self::Oneof => &["oneof"],
1207            Self::View => &["view"],
1208            Self::ViewOneof => &["view", "oneof"],
1209            Self::LazyView => &["lazy_view"],
1210        }
1211    }
1212}
1213
1214/// Build a token-stream path prefix from an emission scope to an ancillary
1215/// kind's location for the **current** message (`proto_fqn`).
1216///
1217/// Always climbs to the package root via `super::` and re-descends through
1218/// `__buffa::<kind>::<msg_path>::` — uniform regardless of where the caller
1219/// sits. `from_nesting` is the caller's total module depth below the
1220/// package root (message-nesting plus any `__buffa::<kind>::` levels the
1221/// caller is already inside).
1222///
1223/// `proto_fqn` follows the dotless convention used throughout codegen
1224/// (e.g. `"google.protobuf.Value"`, not `".google.protobuf.Value"`).
1225///
1226/// Returned tokens always end with `::` so callers append the type
1227/// identifier directly: `quote! { #prefix #ident }`.
1228pub(crate) fn ancillary_prefix(
1229    kind: AncillaryKind,
1230    current_package: &str,
1231    proto_fqn: &str,
1232    from_nesting: usize,
1233) -> proc_macro2::TokenStream {
1234    use crate::idents::make_field_ident;
1235    use quote::quote;
1236
1237    debug_assert!(
1238        !proto_fqn.starts_with('.'),
1239        "ancillary_prefix expects dotless FQN, got {proto_fqn:?}"
1240    );
1241
1242    let mut supers_tokens = proc_macro2::TokenStream::new();
1243    for _ in 0..from_nesting {
1244        supers_tokens.extend(quote! { super:: });
1245    }
1246
1247    let sentinel = make_field_ident(SENTINEL_MOD);
1248    let kind_segs: Vec<_> = kind
1249        .path_segments()
1250        .iter()
1251        .map(|s| make_field_ident(s))
1252        .collect();
1253
1254    // Snake-cased message path within the package (e.g. "outer::inner::").
1255    let within_pkg = if current_package.is_empty() {
1256        proto_fqn
1257    } else {
1258        proto_fqn
1259            .strip_prefix(current_package)
1260            .and_then(|s| s.strip_prefix('.'))
1261            .unwrap_or(proto_fqn)
1262    };
1263    let msg_segs: Vec<_> = within_pkg
1264        .split('.')
1265        .filter(|s| !s.is_empty())
1266        .map(|name| make_field_ident(&to_snake_case(name)))
1267        .collect();
1268
1269    quote! { #supers_tokens #sentinel :: #(#kind_segs ::)* #(#msg_segs ::)* }
1270}
1271
1272/// Proto-segment-aware prefix match: `prefix` matches `fqn_dotted` if
1273/// `prefix == "."`, the two are equal, or `fqn_dotted` starts with `prefix`
1274/// followed by a `.` boundary. Proto identifiers are ASCII, and `.` is ASCII,
1275/// so byte indexing is safe.
1276pub(crate) fn matches_proto_prefix(prefix: &str, fqn_dotted: &str) -> bool {
1277    prefix == "."
1278        || prefix == fqn_dotted
1279        || (fqn_dotted.starts_with(prefix)
1280            && fqn_dotted.as_bytes().get(prefix.len()) == Some(&b'.'))
1281}
1282
1283/// Look up a file-level extern mapping by exact proto file path.
1284///
1285/// Unlike [`resolve_extern_prefix`], this does no prefix or package
1286/// arithmetic — file-level mappings are auto-injected for a known closed set
1287/// of bootstrap protos (`descriptor.proto`, `compiler/plugin.proto`) whose
1288/// types live in a different crate (`buffa-descriptor`) than the rest of the
1289/// `google.protobuf` package (`buffa-types`). The mapped Rust module is the
1290/// root of that file's generated module, with no further suffix.
1291fn resolve_file_extern<'p>(
1292    file_name: &str,
1293    file_extern_paths: &'p [(String, String)],
1294) -> Option<&'p str> {
1295    file_extern_paths
1296        .iter()
1297        .find(|(name, _)| name == file_name)
1298        .map(|(_, rust)| rust.as_str())
1299}
1300
1301/// Check if a proto package matches any extern_path prefix.
1302///
1303/// Returns the Rust module path root if matched, including any remaining
1304/// package segments converted to `snake_case` modules. For example,
1305/// extern_path `(".my", "::my_crate")` with package `"my.sub.pkg"` returns
1306/// `"::my_crate::sub::pkg"`.
1307///
1308/// `pub(crate)`: also called from `crate::effective_file_extern_paths` to
1309/// suppress an auto-injected file-level mapping when a user package-level
1310/// extern_path already covers that file's package.
1311pub(crate) fn resolve_extern_prefix(
1312    package: &str,
1313    extern_paths: &[(String, String)],
1314) -> Option<String> {
1315    let dotted = format!(".{}", package);
1316
1317    // Try longest prefix first so that more specific mappings take priority
1318    // over broader ones (e.g., ".my.common" before ".my").
1319    let mut best: Option<(&str, &str, usize)> = None;
1320
1321    for (proto_prefix, rust_prefix) in extern_paths {
1322        if dotted == *proto_prefix {
1323            // Exact match is always the best.
1324            return Some(rust_prefix.clone());
1325        }
1326        if let Some(rest) = dotted.strip_prefix(proto_prefix.as_str()) {
1327            // `"."` is the catch-all root; stripping it leaves no leading dot.
1328            if proto_prefix == "." || rest.starts_with('.') {
1329                let prefix_len = proto_prefix.len();
1330                // MSRV: `Option::is_none_or` requires 1.82.
1331                if best.map_or(true, |(_, _, best_len)| prefix_len > best_len) {
1332                    best = Some((proto_prefix, rust_prefix, prefix_len));
1333                }
1334            }
1335        }
1336    }
1337
1338    let (proto_prefix, rust_prefix, _) = best?;
1339    let rest = dotted.strip_prefix(proto_prefix)?;
1340    let rest = rest.strip_prefix('.').unwrap_or(rest);
1341    let suffix = rest
1342        .split('.')
1343        .map(to_snake_case)
1344        .collect::<Vec<_>>()
1345        .join("::");
1346    Some(format!("{}::{}", rust_prefix, suffix))
1347}
1348
1349/// Resolve a fully-qualified proto **type** name against `extern_paths`,
1350/// mirroring prost's `resolve_ident` (issue #111).
1351///
1352/// Unlike [`resolve_extern_prefix`] — which only matches a file's *package* and
1353/// returns the package's Rust module — this matches the type's whole FQN:
1354///
1355/// 1. An **exact** `extern_path` entry for the FQN wins (a per-type override,
1356///    e.g. `.google.protobuf.Timestamp = ::pbjson_types::Timestamp`).
1357/// 2. Otherwise the **longest dotted-prefix** entry (a package or an enclosing
1358///    type) applies, with the proto segments past that prefix rendered as
1359///    `snake_case` modules and the final segment kept as the Rust type name —
1360///    exactly the path [`CodeGenContext::new`] would otherwise build from
1361///    [`resolve_extern_prefix`] plus the type name, so package-prefix mappings
1362///    resolve identically to before.
1363///
1364/// `fqn` is the leading-dot form (e.g. `.google.protobuf.Timestamp`). Returns
1365/// the full Rust type path, or `None` when nothing matches (the caller falls
1366/// back to the local package path).
1367fn resolve_extern_type(fqn: &str, extern_paths: &[(String, String)]) -> Option<String> {
1368    // 1. Exact per-type entry — the mapping *is* the full Rust path.
1369    if let Some((_, rust)) = extern_paths.iter().find(|(proto, _)| proto == fqn) {
1370        return Some(rust.clone());
1371    }
1372
1373    // 2. Longest dotted-prefix entry (package or enclosing type).
1374    let mut best: Option<(&str, &str, usize)> = None;
1375    for (proto_prefix, rust_prefix) in extern_paths {
1376        let matches = proto_prefix == "."
1377            || fqn
1378                .strip_prefix(proto_prefix.as_str())
1379                .is_some_and(|rest| rest.starts_with('.'));
1380        // MSRV: `Option::is_none_or` requires 1.82.
1381        if matches && best.map_or(true, |(_, _, best_len)| proto_prefix.len() > best_len) {
1382            best = Some((proto_prefix, rust_prefix, proto_prefix.len()));
1383        }
1384    }
1385
1386    let (proto_prefix, rust_prefix, _) = best?;
1387    // The catch-all `.` leaves the whole FQN (minus its leading dot); any other
1388    // prefix leaves the `.`-separated remainder after the matched boundary.
1389    let rest = if proto_prefix == "." {
1390        fqn.strip_prefix('.').unwrap_or(fqn)
1391    } else {
1392        fqn.strip_prefix(proto_prefix)
1393            .and_then(|r| r.strip_prefix('.'))
1394            .unwrap_or("")
1395    };
1396    let mut segments = rest.split('.').collect::<Vec<_>>();
1397    // The final segment is the type name (kept verbatim); the rest are modules.
1398    let type_name = segments.pop()?;
1399    let mut path = rust_prefix.to_string();
1400    for module in segments {
1401        path.push_str("::");
1402        path.push_str(&to_snake_case(module));
1403    }
1404    path.push_str("::");
1405    path.push_str(type_name);
1406    Some(path)
1407}
1408
1409/// Join a Rust module path and a type name, handling the empty (no-package)
1410/// module by returning the bare type name.
1411fn join_mod(module: &str, name: &str) -> String {
1412    if module.is_empty() {
1413        name.to_string()
1414    } else {
1415        format!("{module}::{name}")
1416    }
1417}
1418
1419/// Resolve a registered type's Rust path, applying per-type `extern_path`
1420/// overrides (issue #111).
1421///
1422/// Priority, most specific first: an **exact** per-type FQN entry, then a
1423/// **file-level** mapping (the internal `descriptor.proto` → `buffa-descriptor`
1424/// split, which must outrank a package prefix), then the **longest
1425/// dotted-prefix** entry (package or enclosing type, via [`resolve_extern_type`]),
1426/// then the **local** package path.
1427///
1428/// Returns `(rust_path, is_extern)`; `is_extern` is `false` only for the local
1429/// fallback, telling the caller whether the type emits a local module (and thus
1430/// participates in sub-package deconfliction, issue #135).
1431fn resolve_type_path(
1432    fqn: &str,
1433    name: &str,
1434    file_root: Option<&str>,
1435    local_module: &str,
1436    extern_paths: &[(String, String)],
1437    type_name_prefix: &str,
1438) -> (String, bool) {
1439    // The exact check is done here (not left to `resolve_extern_type`) so an
1440    // exact per-type entry outranks the file-level mapping below; once it has
1441    // failed, `resolve_extern_type`'s own exact pass can only fall through to
1442    // its prefix logic.
1443    //
1444    // `type_name_prefix` applies only to the local fallback: extern-mapped
1445    // types are named by the external crate, and the file-level root is the
1446    // internal descriptor.proto → buffa-descriptor split (also external to
1447    // this codegen run).
1448    if let Some((_, rust)) = extern_paths.iter().find(|(proto, _)| proto == fqn) {
1449        (rust.clone(), true)
1450    } else if let Some(root) = file_root {
1451        (join_mod(root, name), true)
1452    } else if let Some(path) = resolve_extern_type(fqn, extern_paths) {
1453        (path, true)
1454    } else {
1455        (
1456            join_mod(local_module, &format!("{type_name_prefix}{name}")),
1457            false,
1458        )
1459    }
1460}
1461
1462/// Per-file invariants of [`register_nested_types`], bundled so the
1463/// recursion only threads the parent FQN / module that actually vary.
1464#[derive(Clone, Copy)]
1465struct NestedRegistrationCtx<'a> {
1466    package: &'a str,
1467    extern_paths: &'a [(String, String)],
1468    type_name_prefix: &'a str,
1469}
1470
1471/// Recursively register nested messages and enums with module-qualified paths.
1472///
1473/// Each nested message `Parent.Child` maps to `parent_mod::Child` in Rust,
1474/// where `parent_mod` is the snake_case module path of the enclosing message.
1475///
1476/// A per-type `extern_path` override (issue #111) on a nested type's own FQN
1477/// takes priority over the inherited `parent_mod` path; otherwise the nested
1478/// type simply lives in `parent_mod` — which already reflects any override of an
1479/// enclosing type, so a parent override cascades to its descendants.
1480fn register_nested_types(
1481    type_map: &mut HashMap<String, String>,
1482    package_of: &mut HashMap<String, String>,
1483    reg: NestedRegistrationCtx<'_>,
1484    parent_fqn: &str,
1485    parent_mod: &str,
1486    msg: &crate::generated::descriptor::DescriptorProto,
1487) {
1488    let NestedRegistrationCtx {
1489        package,
1490        extern_paths,
1491        type_name_prefix,
1492    } = reg;
1493    for nested in &msg.nested_type {
1494        if let Some(name) = &nested.name {
1495            let fqn = format!("{}.{}", parent_fqn, name);
1496            // An exact per-type override wins; the child module is then the
1497            // override's parent plus the plain snake_case name. Otherwise the
1498            // type lives in `parent_mod`, named with the configured prefix
1499            // (the module segment stays the proto-derived snake_case name —
1500            // modules never collide with type names).
1501            let (rust_path, child_mod) = match extern_paths.iter().find(|(proto, _)| proto == &fqn)
1502            {
1503                Some((_, rust)) => {
1504                    let child = match rust.rsplit_once("::") {
1505                        Some((parent, _)) => format!("{parent}::{}", to_snake_case(name)),
1506                        None => to_snake_case(name),
1507                    };
1508                    (rust.clone(), child)
1509                }
1510                None => (
1511                    format!("{parent_mod}::{type_name_prefix}{name}"),
1512                    format!("{parent_mod}::{}", to_snake_case(name)),
1513                ),
1514            };
1515            type_map.insert(fqn.clone(), rust_path);
1516            package_of.insert(fqn.clone(), package.to_string());
1517
1518            // Recurse: nested-of-nested goes in a deeper module.
1519            register_nested_types(type_map, package_of, reg, &fqn, &child_mod, nested);
1520        }
1521    }
1522
1523    for enum_type in &msg.enum_type {
1524        if let Some(name) = &enum_type.name {
1525            let fqn = format!("{}.{}", parent_fqn, name);
1526            let rust_path = extern_paths
1527                .iter()
1528                .find(|(proto, _)| proto == &fqn)
1529                .map(|(_, rust)| rust.clone())
1530                .unwrap_or_else(|| format!("{parent_mod}::{type_name_prefix}{name}"));
1531            type_map.insert(fqn.clone(), rust_path);
1532            package_of.insert(fqn, package.to_string());
1533        }
1534    }
1535}
1536
1537/// Record every enum's first declared value number, keyed by dotted FQN
1538/// (same key form as `enum_closedness`).
1539fn collect_enum_first_values(files: &[FileDescriptorProto]) -> HashMap<String, i32> {
1540    fn record(map: &mut HashMap<String, i32>, prefix: &str, e: &EnumDescriptorProto) {
1541        if let (Some(name), Some(first)) = (e.name.as_deref(), e.value.first()) {
1542            map.insert(format!("{prefix}{name}"), first.number.unwrap_or(0));
1543        }
1544    }
1545    fn walk_msg(map: &mut HashMap<String, i32>, prefix: &str, msg: &DescriptorProto) {
1546        let Some(name) = msg.name.as_deref() else {
1547            return;
1548        };
1549        let child_prefix = format!("{prefix}{name}.");
1550        for e in &msg.enum_type {
1551            record(map, &child_prefix, e);
1552        }
1553        for nested in &msg.nested_type {
1554            walk_msg(map, &child_prefix, nested);
1555        }
1556    }
1557
1558    let mut map = HashMap::new();
1559    for file in files {
1560        let prefix = match file.package.as_deref() {
1561            Some(p) if !p.is_empty() => format!(".{p}."),
1562            _ => ".".to_string(),
1563        };
1564        for e in &file.enum_type {
1565            record(&mut map, &prefix, e);
1566        }
1567        for msg in &file.message_type {
1568            walk_msg(&mut map, &prefix, msg);
1569        }
1570    }
1571    map
1572}
1573
1574/// Resolve and record whether an enum is closed, given its parent's features.
1575fn register_enum_closedness(
1576    map: &mut HashMap<String, bool>,
1577    fqn: &str,
1578    parent_features: &ResolvedFeatures,
1579    enum_desc: &EnumDescriptorProto,
1580) {
1581    let resolved = features::resolve_child(parent_features, features::enum_features(enum_desc));
1582    let closed = resolved.enum_type == features::EnumType::Closed;
1583    map.insert(fqn.to_string(), closed);
1584}
1585
1586/// Walk nested messages and register all enum closedness, resolving features
1587/// through the message hierarchy (file → msg → nested_msg → enum).
1588fn register_nested_enum_closedness(
1589    map: &mut HashMap<String, bool>,
1590    parent_fqn: &str,
1591    parent_features: &ResolvedFeatures,
1592    msg: &DescriptorProto,
1593) {
1594    let msg_features = features::resolve_child(parent_features, features::message_features(msg));
1595    for enum_type in &msg.enum_type {
1596        if let Some(name) = &enum_type.name {
1597            let fqn = format!("{}.{}", parent_fqn, name);
1598            register_enum_closedness(map, &fqn, &msg_features, enum_type);
1599        }
1600    }
1601    for nested in &msg.nested_type {
1602        if let Some(name) = &nested.name {
1603            let fqn = format!("{}.{}", parent_fqn, name);
1604            register_nested_enum_closedness(map, &fqn, &msg_features, nested);
1605        }
1606    }
1607}
1608
1609#[cfg(test)]
1610mod tests {
1611    use super::*;
1612    use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
1613
1614    fn children(segs: &[&str]) -> HashSet<String> {
1615        segs.iter().map(|s| s.to_string()).collect()
1616    }
1617
1618    fn names(ns: &[&str]) -> Vec<String> {
1619        ns.iter().map(|s| s.to_string()).collect()
1620    }
1621
1622    #[test]
1623    fn deconflict_no_collision_keeps_base() {
1624        // No sub-package shares a module name → names are unchanged.
1625        let out = deconflict_package_modules(&names(&["Oof", "Bar"]), &children(&["other"]));
1626        assert_eq!(out, vec!["oof".to_string(), "bar".to_string()]);
1627    }
1628
1629    #[test]
1630    fn deconflict_single_collision_appends_underscore() {
1631        let out = deconflict_package_modules(&names(&["Oof"]), &children(&["oof"]));
1632        assert_eq!(out, vec!["oof_".to_string()]);
1633    }
1634
1635    #[test]
1636    fn deconflict_repeated_append_when_underscore_slot_also_taken() {
1637        // Sub-packages `oof` AND `oof_` both exist → grow past both.
1638        let out = deconflict_package_modules(&names(&["Oof"]), &children(&["oof", "oof_"]));
1639        assert_eq!(out, vec!["oof__".to_string()]);
1640    }
1641
1642    #[test]
1643    fn deconflict_two_messages_racing_to_same_slot_stay_distinct() {
1644        // `Oof` (oof) and `Oof_` (oof_), sub-packages `oof` and `oof_`. Without
1645        // the shared `taken` set both would land on `oof__`.
1646        let out = deconflict_package_modules(&names(&["Oof", "Oof_"]), &children(&["oof", "oof_"]));
1647        assert_eq!(out, vec!["oof__".to_string(), "oof___".to_string()]);
1648        // All distinct and clear of the sub-package modules.
1649        let set: HashSet<&String> = out.iter().collect();
1650        assert_eq!(set.len(), out.len());
1651        assert!(!out.contains(&"oof".to_string()) && !out.contains(&"oof_".to_string()));
1652    }
1653
1654    #[test]
1655    fn deconflict_is_independent_of_declaration_order() {
1656        // Reordering the input must not change which message gets which name.
1657        let ch = children(&["oof", "oof_"]);
1658        let fwd = deconflict_package_modules(&names(&["Oof", "Oof_"]), &ch);
1659        let rev = deconflict_package_modules(&names(&["Oof_", "Oof"]), &ch);
1660        // fwd: [Oof, Oof_]; rev: [Oof_, Oof] — same per-name mapping either way.
1661        assert_eq!(fwd, vec!["oof__".to_string(), "oof___".to_string()]);
1662        assert_eq!(rev, vec!["oof___".to_string(), "oof__".to_string()]);
1663    }
1664
1665    #[test]
1666    fn deconflict_avoids_other_messages_raw_base() {
1667        // `Oof` collides with sub-package `oof`; its `oof_` candidate must also
1668        // avoid the raw module of a sibling message `Oof_`.
1669        let out = deconflict_package_modules(&names(&["Oof", "Oof_"]), &children(&["oof"]));
1670        // Oof -> oof_ is taken by Oof_'s raw base, so Oof -> oof__; Oof_ stays.
1671        assert_eq!(out, vec!["oof__".to_string(), "oof_".to_string()]);
1672    }
1673
1674    #[test]
1675    fn deconflict_never_yields_the_sentinel() {
1676        let out = deconflict_package_modules(&names(&["Buffa"]), &children(&["__buffa", "buffa"]));
1677        // base `buffa` collides; `buffa_` is free, so it is chosen (not __buffa).
1678        assert_eq!(out, vec!["buffa_".to_string()]);
1679        assert_ne!(out[0], SENTINEL_MOD);
1680    }
1681
1682    #[test]
1683    fn child_package_segments_extracts_immediate_segment() {
1684        let pkgs = children(&["foo", "foo.oof", "foo.bar.baz", "foobar"]);
1685        let mut got: Vec<String> = child_package_segments("foo", &pkgs).into_iter().collect();
1686        got.sort();
1687        // `foo.oof` -> oof, `foo.bar.baz` -> bar; `foobar` is not a sub-package.
1688        assert_eq!(got, vec!["bar".to_string(), "oof".to_string()]);
1689    }
1690
1691    fn make_file(
1692        name: &str,
1693        package: &str,
1694        messages: Vec<DescriptorProto>,
1695        enums: Vec<EnumDescriptorProto>,
1696    ) -> FileDescriptorProto {
1697        FileDescriptorProto {
1698            name: Some(name.to_string()),
1699            package: if package.is_empty() {
1700                None
1701            } else {
1702                Some(package.to_string())
1703            },
1704            message_type: messages,
1705            enum_type: enums,
1706            ..Default::default()
1707        }
1708    }
1709
1710    fn msg(name: &str) -> DescriptorProto {
1711        DescriptorProto {
1712            name: Some(name.to_string()),
1713            ..Default::default()
1714        }
1715    }
1716
1717    fn msg_with_nested(name: &str, nested: Vec<DescriptorProto>) -> DescriptorProto {
1718        DescriptorProto {
1719            name: Some(name.to_string()),
1720            nested_type: nested,
1721            ..Default::default()
1722        }
1723    }
1724
1725    fn msg_with_nested_and_enums(
1726        name: &str,
1727        nested: Vec<DescriptorProto>,
1728        enums: Vec<EnumDescriptorProto>,
1729    ) -> DescriptorProto {
1730        DescriptorProto {
1731            name: Some(name.to_string()),
1732            nested_type: nested,
1733            enum_type: enums,
1734            ..Default::default()
1735        }
1736    }
1737
1738    fn enum_desc(name: &str) -> EnumDescriptorProto {
1739        EnumDescriptorProto {
1740            name: Some(name.to_string()),
1741            ..Default::default()
1742        }
1743    }
1744
1745    fn enum_with_closed_feature(name: &str) -> EnumDescriptorProto {
1746        use crate::generated::descriptor::{feature_set, EnumOptions, FeatureSet};
1747        EnumDescriptorProto {
1748            name: Some(name.to_string()),
1749            options: buffa::MessageField::some(EnumOptions {
1750                features: buffa::MessageField::some(FeatureSet {
1751                    enum_type: Some(feature_set::EnumType::CLOSED),
1752                    ..Default::default()
1753                }),
1754                ..Default::default()
1755            }),
1756            ..Default::default()
1757        }
1758    }
1759
1760    fn editions_file(
1761        name: &str,
1762        package: &str,
1763        messages: Vec<DescriptorProto>,
1764        enums: Vec<EnumDescriptorProto>,
1765    ) -> FileDescriptorProto {
1766        use crate::generated::descriptor::Edition;
1767        FileDescriptorProto {
1768            name: Some(name.to_string()),
1769            package: Some(package.to_string()),
1770            syntax: Some("editions".to_string()),
1771            edition: Some(Edition::EDITION_2023),
1772            message_type: messages,
1773            enum_type: enums,
1774            ..Default::default()
1775        }
1776    }
1777
1778    // ── Type registration tests ──────────────────────────────────────────
1779
1780    #[test]
1781    fn test_message_with_package() {
1782        let files = [make_file(
1783            "test.proto",
1784            "my.package",
1785            vec![msg("Foo")],
1786            vec![],
1787        )];
1788        let config = CodeGenConfig::default();
1789        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1790        assert_eq!(ctx.rust_type(".my.package.Foo"), Some("my::package::Foo"));
1791    }
1792
1793    #[test]
1794    fn test_message_no_package() {
1795        let files = [make_file("test.proto", "", vec![msg("Bar")], vec![])];
1796        let config = CodeGenConfig::default();
1797        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1798        assert_eq!(ctx.rust_type(".Bar"), Some("Bar"));
1799    }
1800
1801    #[test]
1802    fn test_nested_message_uses_module_path() {
1803        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1804        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1805        let config = CodeGenConfig::default();
1806        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1807        assert_eq!(ctx.rust_type(".pkg.Outer"), Some("pkg::Outer"));
1808        // Nested types use module-qualified paths.
1809        assert_eq!(ctx.rust_type(".pkg.Outer.Inner"), Some("pkg::outer::Inner"));
1810    }
1811
1812    #[test]
1813    fn test_nested_message_no_package() {
1814        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1815        let files = [make_file("test.proto", "", vec![outer], vec![])];
1816        let config = CodeGenConfig::default();
1817        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1818        assert_eq!(ctx.rust_type(".Outer"), Some("Outer"));
1819        assert_eq!(ctx.rust_type(".Outer.Inner"), Some("outer::Inner"));
1820    }
1821
1822    #[test]
1823    fn test_deeply_nested_message() {
1824        let deep = msg_with_nested("A", vec![msg_with_nested("B", vec![msg("C")])]);
1825        let files = [make_file("test.proto", "pkg", vec![deep], vec![])];
1826        let config = CodeGenConfig::default();
1827        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1828        assert_eq!(ctx.rust_type(".pkg.A"), Some("pkg::A"));
1829        assert_eq!(ctx.rust_type(".pkg.A.B"), Some("pkg::a::B"));
1830        assert_eq!(ctx.rust_type(".pkg.A.B.C"), Some("pkg::a::b::C"));
1831    }
1832
1833    #[test]
1834    fn test_nested_enum_uses_module_path() {
1835        let outer = msg_with_nested_and_enums("Outer", vec![], vec![enum_desc("Status")]);
1836        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1837        let config = CodeGenConfig::default();
1838        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1839        assert_eq!(
1840            ctx.rust_type(".pkg.Outer.Status"),
1841            Some("pkg::outer::Status")
1842        );
1843    }
1844
1845    #[test]
1846    fn test_top_level_enum() {
1847        let files = [make_file(
1848            "test.proto",
1849            "pkg",
1850            vec![],
1851            vec![enum_desc("Status")],
1852        )];
1853        let config = CodeGenConfig::default();
1854        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1855        assert_eq!(ctx.rust_type(".pkg.Status"), Some("pkg::Status"));
1856    }
1857
1858    #[test]
1859    fn test_same_named_nested_types_in_different_parents_are_distinct() {
1860        let outer1 = msg_with_nested("Outer1", vec![msg("Inner")]);
1861        let outer2 = msg_with_nested("Outer2", vec![msg("Inner")]);
1862        let files = [make_file("a.proto", "pkg", vec![outer1, outer2], vec![])];
1863        let config = CodeGenConfig::default();
1864        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1865        // Different parent modules make them distinct.
1866        assert_eq!(
1867            ctx.rust_type(".pkg.Outer1.Inner"),
1868            Some("pkg::outer1::Inner")
1869        );
1870        assert_eq!(
1871            ctx.rust_type(".pkg.Outer2.Inner"),
1872            Some("pkg::outer2::Inner")
1873        );
1874        assert_ne!(
1875            ctx.rust_type(".pkg.Outer1.Inner"),
1876            ctx.rust_type(".pkg.Outer2.Inner")
1877        );
1878    }
1879
1880    #[test]
1881    fn test_multiple_files() {
1882        let files = [
1883            make_file("a.proto", "ns.a", vec![msg("MsgA")], vec![]),
1884            make_file("b.proto", "ns.b", vec![msg("MsgB")], vec![]),
1885        ];
1886        let config = CodeGenConfig::default();
1887        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1888        assert_eq!(ctx.rust_type(".ns.a.MsgA"), Some("ns::a::MsgA"));
1889        assert_eq!(ctx.rust_type(".ns.b.MsgB"), Some("ns::b::MsgB"));
1890    }
1891
1892    // ── Per-type FQN extern_path resolution (issue #111) ──────────────────
1893    //
1894    // `extern_path` historically matched only at the package-prefix level, so
1895    // a mapping naming a concrete type FQN (e.g.
1896    // `.google.protobuf.Timestamp=::pbjson_types::Timestamp`, a normal
1897    // prost/tonic idiom) was silently ignored. These tests pin the prost
1898    // `resolve_ident` behavior: an exact type FQN entry wins, otherwise the
1899    // longest dotted-prefix (package or enclosing type) applies.
1900
1901    #[test]
1902    fn test_extern_path_exact_per_type_match() {
1903        let files = [make_file(
1904            "test.proto",
1905            "test.pkg",
1906            vec![msg("Msg")],
1907            vec![],
1908        )];
1909        let config = CodeGenConfig {
1910            extern_paths: vec![(".test.pkg.Msg".into(), "::ext_crate::Msg".into())],
1911            ..Default::default()
1912        };
1913        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1914        // An exact per-type FQN mapping must be honored, not silently dropped.
1915        assert_eq!(ctx.rust_type(".test.pkg.Msg"), Some("::ext_crate::Msg"));
1916    }
1917
1918    #[test]
1919    fn test_extern_path_per_type_overrides_package_prefix() {
1920        // Package-level mapping covers the whole package; an exact per-type
1921        // entry overrides it for that one type (exact-match-first), while a
1922        // sibling still resolves via the package prefix.
1923        let files = [make_file(
1924            "test.proto",
1925            "test.pkg",
1926            vec![msg("Msg"), msg("Other")],
1927            vec![],
1928        )];
1929        let config = CodeGenConfig {
1930            extern_paths: vec![
1931                (".test.pkg".into(), "::pkg_crate".into()),
1932                (".test.pkg.Msg".into(), "::ext_crate::Msg".into()),
1933            ],
1934            ..Default::default()
1935        };
1936        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1937        assert_eq!(ctx.rust_type(".test.pkg.Msg"), Some("::ext_crate::Msg"));
1938        assert_eq!(ctx.rust_type(".test.pkg.Other"), Some("::pkg_crate::Other"));
1939    }
1940
1941    #[test]
1942    fn test_extern_path_nested_type_inherits_per_type_override() {
1943        // A per-type override of a parent message cascades to its nested
1944        // types via the snake_case module of the overridden path.
1945        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1946        let files = [make_file("test.proto", "test.pkg", vec![outer], vec![])];
1947        let config = CodeGenConfig {
1948            extern_paths: vec![(".test.pkg.Outer".into(), "::ext::Outer".into())],
1949            ..Default::default()
1950        };
1951        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1952        assert_eq!(ctx.rust_type(".test.pkg.Outer"), Some("::ext::Outer"));
1953        assert_eq!(
1954            ctx.rust_type(".test.pkg.Outer.Inner"),
1955            Some("::ext::outer::Inner")
1956        );
1957    }
1958
1959    #[test]
1960    fn test_extern_path_exact_per_type_enum() {
1961        let files = [make_file(
1962            "test.proto",
1963            "test.pkg",
1964            vec![],
1965            vec![enum_desc("Status")],
1966        )];
1967        let config = CodeGenConfig {
1968            extern_paths: vec![(".test.pkg.Status".into(), "::ext_crate::Status".into())],
1969            ..Default::default()
1970        };
1971        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1972        assert_eq!(
1973            ctx.rust_type(".test.pkg.Status"),
1974            Some("::ext_crate::Status")
1975        );
1976    }
1977
1978    #[test]
1979    fn test_extern_path_package_prefix_still_resolves() {
1980        // Guard: package-prefix mappings (the only historically-supported
1981        // form) must keep resolving exactly as before.
1982        let files = [make_file(
1983            "test.proto",
1984            "test.pkg",
1985            vec![msg("Msg")],
1986            vec![],
1987        )];
1988        let config = CodeGenConfig {
1989            extern_paths: vec![(".test.pkg".into(), "::pkg_crate".into())],
1990            ..Default::default()
1991        };
1992        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1993        assert_eq!(ctx.rust_type(".test.pkg.Msg"), Some("::pkg_crate::Msg"));
1994    }
1995
1996    #[test]
1997    fn test_extern_path_per_type_does_not_affect_unmapped_type() {
1998        // Guard: a per-type entry must not leak onto an unrelated type.
1999        let files = [make_file(
2000            "test.proto",
2001            "test.pkg",
2002            vec![msg("Msg"), msg("Other")],
2003            vec![],
2004        )];
2005        let config = CodeGenConfig {
2006            extern_paths: vec![(".test.pkg.Msg".into(), "::ext_crate::Msg".into())],
2007            ..Default::default()
2008        };
2009        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2010        assert_eq!(ctx.rust_type(".test.pkg.Msg"), Some("::ext_crate::Msg"));
2011        // `Other` has no entry — resolves locally.
2012        assert_eq!(ctx.rust_type(".test.pkg.Other"), Some("test::pkg::Other"));
2013    }
2014
2015    #[test]
2016    fn test_keyword_package_segment_in_type_map() {
2017        // Proto package `google.type` — the type map stores plain string paths.
2018        // Keyword escaping happens at the token level, not in the type map.
2019        let files = [make_file(
2020            "latlng.proto",
2021            "google.type",
2022            vec![msg("LatLng")],
2023            vec![],
2024        )];
2025        let config = CodeGenConfig::default();
2026        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2027        assert_eq!(
2028            ctx.rust_type(".google.type.LatLng"),
2029            Some("google::type::LatLng")
2030        );
2031    }
2032
2033    #[test]
2034    fn test_keyword_package_relative_same_package() {
2035        let files = [make_file(
2036            "latlng.proto",
2037            "google.type",
2038            vec![msg("LatLng"), msg("Expr")],
2039            vec![],
2040        )];
2041        let config = CodeGenConfig::default();
2042        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2043        // Same-package reference: just the type name (no module prefix).
2044        assert_eq!(
2045            ctx.rust_type_relative(".google.type.LatLng", "google.type", 0),
2046            Some("LatLng".into())
2047        );
2048    }
2049
2050    #[test]
2051    fn test_keyword_package_cross_package() {
2052        let files = [
2053            make_file("latlng.proto", "google.type", vec![msg("LatLng")], vec![]),
2054            make_file("svc.proto", "google.cloud", vec![msg("Service")], vec![]),
2055        ];
2056        let config = CodeGenConfig::default();
2057        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2058        // Cross-package: relative path via super:: (keyword escaping at token level).
2059        // From google.cloud, go up one (past "cloud"), then into "type".
2060        assert_eq!(
2061            ctx.rust_type_relative(".google.type.LatLng", "google.cloud", 0),
2062            Some("super::type::LatLng".into())
2063        );
2064    }
2065
2066    #[test]
2067    fn test_keyword_nested_message_module() {
2068        // Message named "Type" → module "type" in type map.
2069        let outer = msg_with_nested("Type", vec![msg("Inner")]);
2070        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
2071        let config = CodeGenConfig::default();
2072        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2073        assert_eq!(ctx.rust_type(".pkg.Type"), Some("pkg::Type"));
2074        assert_eq!(ctx.rust_type(".pkg.Type.Inner"), Some("pkg::type::Inner"));
2075    }
2076
2077    #[test]
2078    fn test_unknown_type_returns_none() {
2079        let files = [make_file("test.proto", "pkg", vec![msg("Foo")], vec![])];
2080        let config = CodeGenConfig::default();
2081        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2082        assert_eq!(ctx.rust_type(".pkg.Unknown"), None);
2083    }
2084
2085    // ── Relative type resolution tests ───────────────────────────────────
2086
2087    #[test]
2088    fn test_relative_same_package_top_level() {
2089        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
2090        let config = CodeGenConfig::default();
2091        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2092        // From top-level in same package: just the type name.
2093        assert_eq!(
2094            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
2095            Some("Foo".into())
2096        );
2097    }
2098
2099    #[test]
2100    fn test_relative_cross_package() {
2101        let files = [
2102            make_file("a.proto", "pkg_a", vec![msg("Foo")], vec![]),
2103            make_file("b.proto", "pkg_b", vec![msg("Bar")], vec![]),
2104        ];
2105        let config = CodeGenConfig::default();
2106        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2107        // Cross-package: relative via super:: (up one from pkg_b, into pkg_a).
2108        assert_eq!(
2109            ctx.rust_type_relative(".pkg_a.Foo", "pkg_b", 0),
2110            Some("super::pkg_a::Foo".into())
2111        );
2112    }
2113
2114    #[test]
2115    fn test_relative_no_package() {
2116        let files = [make_file("a.proto", "", vec![msg("Foo")], vec![])];
2117        let config = CodeGenConfig::default();
2118        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2119        assert_eq!(ctx.rust_type_relative(".Foo", "", 0), Some("Foo".into()));
2120    }
2121
2122    #[test]
2123    fn test_relative_unknown_returns_none() {
2124        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
2125        let config = CodeGenConfig::default();
2126        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2127        assert_eq!(ctx.rust_type_relative(".pkg.Unknown", "pkg", 0), None);
2128    }
2129
2130    #[test]
2131    fn test_relative_dotted_package() {
2132        let files = [make_file("a.proto", "my.pkg", vec![msg("Foo")], vec![])];
2133        let config = CodeGenConfig::default();
2134        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2135        assert_eq!(
2136            ctx.rust_type_relative(".my.pkg.Foo", "my.pkg", 0),
2137            Some("Foo".into())
2138        );
2139    }
2140
2141    #[test]
2142    fn test_relative_cross_dotted_packages() {
2143        let files = [
2144            make_file(
2145                "timestamp.proto",
2146                "google.protobuf",
2147                vec![msg("Timestamp")],
2148                vec![],
2149            ),
2150            make_file(
2151                "test.proto",
2152                "protobuf_test_messages.proto3",
2153                vec![msg("TestAllTypesProto3")],
2154                vec![],
2155            ),
2156        ];
2157        let config = CodeGenConfig::default();
2158        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2159
2160        // Cross-package: relative via super:: (no common prefix, up 2 levels).
2161        assert_eq!(
2162            ctx.rust_type_relative(
2163                ".google.protobuf.Timestamp",
2164                "protobuf_test_messages.proto3",
2165                0,
2166            ),
2167            Some("super::super::google::protobuf::Timestamp".into())
2168        );
2169    }
2170
2171    #[test]
2172    fn test_relative_nested_type_from_same_package() {
2173        // Referencing Outer.Inner from the same package.
2174        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
2175        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
2176        let config = CodeGenConfig::default();
2177        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2178
2179        // Same package: strips the package prefix, keeps module path.
2180        assert_eq!(
2181            ctx.rust_type_relative(".pkg.Outer.Inner", "pkg", 0),
2182            Some("outer::Inner".into())
2183        );
2184    }
2185
2186    #[test]
2187    fn test_relative_shared_prefix_not_confused() {
2188        let files = [
2189            make_file("ab.proto", "a.b", vec![msg("Msg1")], vec![]),
2190            make_file("abc.proto", "a.bc", vec![msg("Msg2")], vec![]),
2191        ];
2192        let config = CodeGenConfig::default();
2193        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2194
2195        // `a.b.Msg1` from `a.bc` context: common prefix "a", up 1, into "b".
2196        assert_eq!(
2197            ctx.rust_type_relative(".a.b.Msg1", "a.bc", 0),
2198            Some("super::b::Msg1".into())
2199        );
2200        // `a.bc.Msg2` from `a.b` context: common prefix "a", up 1, into "bc".
2201        assert_eq!(
2202            ctx.rust_type_relative(".a.bc.Msg2", "a.b", 0),
2203            Some("super::bc::Msg2".into())
2204        );
2205    }
2206
2207    // ── Nesting depth tests ────────────────────────────────────────────
2208
2209    #[test]
2210    fn test_relative_cross_package_nesting_1() {
2211        // Simulates a nested message (inside a `pub mod`) referencing a type
2212        // from a sibling package. E.g., account.business.admin.v1 nested msg
2213        // referencing account.business.v1.Business.Status.
2214        let outer = msg_with_nested_and_enums("Business", vec![], vec![enum_desc("Status")]);
2215        let files = [
2216            make_file("admin.proto", "a.b.admin.v1", vec![msg("Svc")], vec![]),
2217            make_file("biz.proto", "a.b.v1", vec![outer], vec![]),
2218        ];
2219        let config = CodeGenConfig::default();
2220        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2221
2222        // nesting=0 (top-level struct in admin.v1): up 2 (v1→admin), into v1
2223        assert_eq!(
2224            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 0),
2225            Some("super::super::v1::business::Status".into())
2226        );
2227        // nesting=1 (inside a nested message module): one extra super::
2228        assert_eq!(
2229            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 1),
2230            Some("super::super::super::v1::business::Status".into())
2231        );
2232    }
2233
2234    #[test]
2235    fn test_relative_same_package_nesting_1() {
2236        // Nested message referencing a sibling type in the same package.
2237        let files = [make_file(
2238            "test.proto",
2239            "pkg",
2240            vec![msg("Foo"), msg("Bar")],
2241            vec![],
2242        )];
2243        let config = CodeGenConfig::default();
2244        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2245
2246        // nesting=0: same package, just the name
2247        assert_eq!(
2248            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
2249            Some("Foo".into())
2250        );
2251        // nesting=1: inside a message module, needs one super::
2252        assert_eq!(
2253            ctx.rust_type_relative(".pkg.Foo", "pkg", 1),
2254            Some("super::Foo".into())
2255        );
2256        // nesting=2: doubly nested
2257        assert_eq!(
2258            ctx.rust_type_relative(".pkg.Foo", "pkg", 2),
2259            Some("super::super::Foo".into())
2260        );
2261    }
2262
2263    // ── Extern path tests ─────────────────────────────────────────────
2264
2265    #[test]
2266    fn test_resolve_file_extern_exact_match_only() {
2267        let mappings = [(
2268            "google/protobuf/descriptor.proto".to_string(),
2269            "::buffa_descriptor::generated::descriptor".to_string(),
2270        )];
2271        // Exact file path matches.
2272        assert_eq!(
2273            resolve_file_extern("google/protobuf/descriptor.proto", &mappings),
2274            Some("::buffa_descriptor::generated::descriptor"),
2275        );
2276        // No prefix matching — a sibling file in the same directory does
2277        // not match (its types live in a different generated module).
2278        assert_eq!(
2279            resolve_file_extern("google/protobuf/timestamp.proto", &mappings),
2280            None,
2281        );
2282        // No suffix matching either.
2283        assert_eq!(
2284            resolve_file_extern("vendor/google/protobuf/descriptor.proto", &mappings),
2285            None,
2286        );
2287    }
2288
2289    #[test]
2290    fn test_resolve_extern_prefix_exact_match() {
2291        let result = resolve_extern_prefix(
2292            "my.common",
2293            &[(".my.common".into(), "::common_protos".into())],
2294        );
2295        assert_eq!(result, Some("::common_protos".into()));
2296    }
2297
2298    #[test]
2299    fn test_resolve_extern_prefix_sub_package() {
2300        let result = resolve_extern_prefix(
2301            "my.common.sub",
2302            &[(".my.common".into(), "::common_protos".into())],
2303        );
2304        assert_eq!(result, Some("::common_protos::sub".into()));
2305    }
2306
2307    #[test]
2308    fn test_resolve_extern_prefix_no_match() {
2309        let result = resolve_extern_prefix(
2310            "other.pkg",
2311            &[(".my.common".into(), "::common_protos".into())],
2312        );
2313        assert_eq!(result, None);
2314    }
2315
2316    #[test]
2317    fn test_resolve_extern_prefix_partial_name_no_match() {
2318        // ".my.common" should not match ".my.commonext"
2319        let result = resolve_extern_prefix(
2320            "my.commonext",
2321            &[(".my.common".into(), "::common_protos".into())],
2322        );
2323        assert_eq!(result, None);
2324    }
2325
2326    #[test]
2327    fn test_resolve_extern_prefix_longest_match_wins() {
2328        // When multiple prefixes match, the longest one should win.
2329        let result = resolve_extern_prefix(
2330            "my.common.sub",
2331            &[
2332                (".my".into(), "::crate_a".into()),
2333                (".my.common".into(), "::crate_b".into()),
2334            ],
2335        );
2336        assert_eq!(result, Some("::crate_b::sub".into()));
2337    }
2338
2339    #[test]
2340    fn test_resolve_extern_prefix_catchall() {
2341        let result = resolve_extern_prefix("greet.v1", &[(".".into(), "crate::proto".into())]);
2342        assert_eq!(result, Some("crate::proto::greet::v1".into()));
2343    }
2344
2345    #[test]
2346    fn test_resolve_extern_prefix_catchall_empty_pkg() {
2347        // Empty package with `.` catch-all hits the exact-match branch
2348        // (dotted == "." == proto_prefix) and returns the root as-is.
2349        let result = resolve_extern_prefix("", &[(".".into(), "crate::proto".into())]);
2350        assert_eq!(result, Some("crate::proto".into()));
2351    }
2352
2353    #[test]
2354    fn test_resolve_extern_prefix_catchall_longest_wins() {
2355        // `.` catch-all is the shortest possible prefix; any more-specific
2356        // mapping (including the auto-injected WKT mapping) takes priority.
2357        let result = resolve_extern_prefix(
2358            "google.protobuf",
2359            &[
2360                (".".into(), "crate::proto".into()),
2361                (
2362                    ".google.protobuf".into(),
2363                    "::buffa_types::google::protobuf".into(),
2364                ),
2365            ],
2366        );
2367        assert_eq!(result, Some("::buffa_types::google::protobuf".into()));
2368    }
2369
2370    #[test]
2371    fn test_resolve_extern_prefix_catchall_keyword_package() {
2372        // Keyword segments stay unescaped at the string level; escaping to
2373        // `r#type` happens later in `idents::rust_path_to_tokens`.
2374        let result = resolve_extern_prefix("google.type", &[(".".into(), "crate::proto".into())]);
2375        assert_eq!(result, Some("crate::proto::google::type".into()));
2376    }
2377
2378    // ── resolve_extern_type — per-type FQN resolution (issue #111) ─────────
2379
2380    #[test]
2381    fn test_resolve_extern_type_exact_match() {
2382        // An exact entry for the type FQN is the full Rust path verbatim.
2383        let result = resolve_extern_type(
2384            ".google.protobuf.Timestamp",
2385            &[(
2386                ".google.protobuf.Timestamp".into(),
2387                "::pbjson_types::Timestamp".into(),
2388            )],
2389        );
2390        assert_eq!(result, Some("::pbjson_types::Timestamp".into()));
2391    }
2392
2393    #[test]
2394    fn test_resolve_extern_type_exact_wins_over_prefix() {
2395        // Exact-match-first: the per-type entry beats a covering package entry.
2396        let result = resolve_extern_type(
2397            ".my.pkg.Msg",
2398            &[
2399                (".my.pkg".into(), "::pkg_crate".into()),
2400                (".my.pkg.Msg".into(), "::ext_crate::Msg".into()),
2401            ],
2402        );
2403        assert_eq!(result, Some("::ext_crate::Msg".into()));
2404    }
2405
2406    #[test]
2407    fn test_resolve_extern_type_package_prefix_appends_type() {
2408        // A package prefix yields `<rust_prefix>::<sub modules>::<TypeName>`,
2409        // matching what resolve_extern_prefix + the type name would build.
2410        let result = resolve_extern_type(
2411            ".my.common.sub.Msg",
2412            &[(".my.common".into(), "::common_protos".into())],
2413        );
2414        assert_eq!(result, Some("::common_protos::sub::Msg".into()));
2415    }
2416
2417    #[test]
2418    fn test_resolve_extern_type_catchall() {
2419        let result = resolve_extern_type(".greet.v1.Hello", &[(".".into(), "crate::proto".into())]);
2420        assert_eq!(result, Some("crate::proto::greet::v1::Hello".into()));
2421    }
2422
2423    #[test]
2424    fn test_resolve_extern_type_no_match() {
2425        let result = resolve_extern_type(
2426            ".other.pkg.Msg",
2427            &[(".my.common".into(), "::common_protos".into())],
2428        );
2429        assert_eq!(result, None);
2430    }
2431
2432    #[test]
2433    fn test_resolve_extern_type_partial_name_no_match() {
2434        // ".my.common" must not match ".my.commonext.Msg" (dot-boundary).
2435        let result = resolve_extern_type(
2436            ".my.commonext.Msg",
2437            &[(".my.common".into(), "::common_protos".into())],
2438        );
2439        assert_eq!(result, None);
2440    }
2441
2442    // ── rust_type_relative_split — extern branch ────────────────────────
2443
2444    #[test]
2445    fn test_split_extern_top_level() {
2446        let outer = msg_with_nested("Value", vec![msg("Inner")]);
2447        let files = [make_file(
2448            "struct.proto",
2449            "google.protobuf",
2450            vec![outer],
2451            vec![],
2452        )];
2453        let config = CodeGenConfig::default();
2454        let extern_paths = vec![(
2455            ".google.protobuf".into(),
2456            "::buffa_types::google::protobuf".into(),
2457        )];
2458        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
2459
2460        let split = ctx
2461            .rust_type_relative_split(".google.protobuf.Value", "my.pkg", 3)
2462            .expect("type resolves");
2463        assert!(split.is_extern);
2464        // Extern path is absolute → nesting irrelevant.
2465        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
2466        assert_eq!(split.within_package, "Value");
2467    }
2468
2469    #[test]
2470    fn test_split_extern_nested_type() {
2471        // Nested `.google.protobuf.Value.Inner` →
2472        // extern path `::buffa_types::google::protobuf::value::Inner`.
2473        // Segment-count slice: 2 within-package segments → cut after the
2474        // extern module prefix.
2475        let outer = msg_with_nested("Value", vec![msg("Inner")]);
2476        let files = [make_file(
2477            "struct.proto",
2478            "google.protobuf",
2479            vec![outer],
2480            vec![],
2481        )];
2482        let config = CodeGenConfig::default();
2483        let extern_paths = vec![(
2484            ".google.protobuf".into(),
2485            "::buffa_types::google::protobuf".into(),
2486        )];
2487        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
2488
2489        let split = ctx
2490            .rust_type_relative_split(".google.protobuf.Value.Inner", "my.pkg", 0)
2491            .expect("nested type resolves");
2492        assert!(split.is_extern);
2493        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
2494        assert_eq!(split.within_package, "value::Inner");
2495    }
2496
2497    #[test]
2498    fn test_split_per_type_extern_override() {
2499        // Per-type override (issue #111): the `__buffa::` boundary recovery must
2500        // still split the override path at the package/within-package seam, so
2501        // callers compose `<to_package>::__buffa::view::<within_package>`
2502        // correctly. Both the overridden type and its nested children are
2503        // exercised.
2504        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
2505        let files = [make_file("custom.proto", "my.pkg", vec![outer], vec![])];
2506        let config = CodeGenConfig {
2507            extern_paths: vec![(".my.pkg.Outer".into(), "::ext::custom::Outer".into())],
2508            ..Default::default()
2509        };
2510        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2511
2512        let split = ctx
2513            .rust_type_relative_split(".my.pkg.Outer", "other.pkg", 2)
2514            .expect("overridden type resolves");
2515        assert!(split.is_extern);
2516        assert_eq!(split.to_package, "::ext::custom");
2517        assert_eq!(split.within_package, "Outer");
2518
2519        // The nested type inherits the override's module, and the seam is
2520        // recovered the same way (2 within-package segments stripped).
2521        let nested = ctx
2522            .rust_type_relative_split(".my.pkg.Outer.Inner", "other.pkg", 0)
2523            .expect("nested type resolves");
2524        assert!(nested.is_extern);
2525        assert_eq!(nested.to_package, "::ext::custom");
2526        assert_eq!(nested.within_package, "outer::Inner");
2527    }
2528
2529    #[test]
2530    fn test_extern_path_top_level_message() {
2531        let files = [make_file(
2532            "common.proto",
2533            "my.common",
2534            vec![msg("SharedMsg")],
2535            vec![],
2536        )];
2537        let config = CodeGenConfig {
2538            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2539            ..Default::default()
2540        };
2541        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2542        assert_eq!(
2543            ctx.rust_type(".my.common.SharedMsg"),
2544            Some("::common_protos::SharedMsg")
2545        );
2546    }
2547
2548    #[test]
2549    fn test_extern_path_nested_message() {
2550        let files = [make_file(
2551            "common.proto",
2552            "my.common",
2553            vec![msg_with_nested("Outer", vec![msg("Inner")])],
2554            vec![],
2555        )];
2556        let config = CodeGenConfig {
2557            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2558            ..Default::default()
2559        };
2560        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2561        assert_eq!(
2562            ctx.rust_type(".my.common.Outer"),
2563            Some("::common_protos::Outer")
2564        );
2565        assert_eq!(
2566            ctx.rust_type(".my.common.Outer.Inner"),
2567            Some("::common_protos::outer::Inner")
2568        );
2569    }
2570
2571    #[test]
2572    fn test_extern_path_nested_types_use_deconflicted_module() {
2573        // Mirrors pb.lyft.Money.{Currency,Breakdown} vs sub-package pb.lyft.money.
2574        let money =
2575            msg_with_nested_and_enums("Money", vec![msg("Breakdown")], vec![enum_desc("Currency")]);
2576        let files = [
2577            make_file("lyft_money.proto", "pb.lyft", vec![money], vec![]),
2578            make_file("money.proto", "pb.lyft.money", vec![msg("Money")], vec![]),
2579            make_file(
2580                "users.proto",
2581                "pb.lyft.users",
2582                vec![msg("DailyTotalFares")],
2583                vec![],
2584            ),
2585        ];
2586        let config = CodeGenConfig {
2587            extern_paths: vec![(".pb.lyft".into(), "::idl_pb_lyft::pb::lyft".into())],
2588            ..Default::default()
2589        };
2590        let ctx = CodeGenContext::for_generate(&files, &["users.proto".to_string()], &config);
2591        assert_eq!(
2592            ctx.rust_type(".pb.lyft.Money.Currency"),
2593            Some("::idl_pb_lyft::pb::lyft::money_::Currency")
2594        );
2595        assert_eq!(
2596            ctx.rust_type(".pb.lyft.Money.Breakdown"),
2597            Some("::idl_pb_lyft::pb::lyft::money_::Breakdown")
2598        );
2599    }
2600
2601    #[test]
2602    fn test_extern_path_enum() {
2603        let files = [make_file(
2604            "common.proto",
2605            "my.common",
2606            vec![],
2607            vec![enum_desc("Status")],
2608        )];
2609        let config = CodeGenConfig {
2610            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2611            ..Default::default()
2612        };
2613        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2614        assert_eq!(
2615            ctx.rust_type(".my.common.Status"),
2616            Some("::common_protos::Status")
2617        );
2618    }
2619
2620    #[test]
2621    fn test_extern_path_does_not_affect_other_packages() {
2622        let files = [
2623            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
2624            make_file(
2625                "service.proto",
2626                "my.service",
2627                vec![msg("MyService")],
2628                vec![],
2629            ),
2630        ];
2631        let config = CodeGenConfig {
2632            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2633            ..Default::default()
2634        };
2635        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2636        // Extern type uses absolute path.
2637        assert_eq!(
2638            ctx.rust_type(".my.common.SharedMsg"),
2639            Some("::common_protos::SharedMsg")
2640        );
2641        // Non-extern type uses normal package-derived path.
2642        assert_eq!(
2643            ctx.rust_type(".my.service.MyService"),
2644            Some("my::service::MyService")
2645        );
2646    }
2647
2648    #[test]
2649    fn test_extern_path_relative_returns_absolute() {
2650        // When an extern type is referenced from another package,
2651        // rust_type_relative should return the full absolute path.
2652        let files = [
2653            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
2654            make_file(
2655                "service.proto",
2656                "my.service",
2657                vec![msg("MyService")],
2658                vec![],
2659            ),
2660        ];
2661        let config = CodeGenConfig {
2662            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
2663            ..Default::default()
2664        };
2665        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2666        // Cross-package reference to extern type: absolute path.
2667        assert_eq!(
2668            ctx.rust_type_relative(".my.common.SharedMsg", "my.service", 0),
2669            Some("::common_protos::SharedMsg".into())
2670        );
2671    }
2672
2673    // ── is_enum_closed tests ──────────────────────────────────────────────
2674
2675    #[test]
2676    fn test_is_enum_closed_proto3_default_open() {
2677        let files = [make_file("a.proto", "p", vec![], vec![enum_desc("E")])];
2678        let config = CodeGenConfig::default();
2679        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2680        // proto3 default (make_file has no syntax = proto2/implicit)
2681        // actually make_file doesn't set syntax, so it's proto2 default...
2682        // proto2 default is CLOSED.
2683        assert_eq!(ctx.is_enum_closed(".p.E"), Some(true));
2684    }
2685
2686    #[test]
2687    fn test_is_enum_closed_editions_default_open() {
2688        let files = [editions_file("a.proto", "p", vec![], vec![enum_desc("E")])];
2689        let config = CodeGenConfig::default();
2690        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2691        // Edition 2023 default is OPEN.
2692        assert_eq!(ctx.is_enum_closed(".p.E"), Some(false));
2693    }
2694
2695    #[test]
2696    fn test_is_enum_closed_per_enum_override() {
2697        // This is THE bug: enum with `option features.enum_type = CLOSED`
2698        // in an otherwise-open editions file must be detected as closed.
2699        let files = [editions_file(
2700            "a.proto",
2701            "p",
2702            vec![],
2703            vec![enum_desc("Open"), enum_with_closed_feature("Closed")],
2704        )];
2705        let config = CodeGenConfig::default();
2706        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2707        assert_eq!(ctx.is_enum_closed(".p.Open"), Some(false));
2708        assert_eq!(ctx.is_enum_closed(".p.Closed"), Some(true));
2709    }
2710
2711    #[test]
2712    fn test_is_enum_closed_nested_per_enum_override() {
2713        // Feature resolution through file → message → enum.
2714        let files = [editions_file(
2715            "a.proto",
2716            "p",
2717            vec![msg_with_nested_and_enums(
2718                "M",
2719                vec![],
2720                vec![enum_with_closed_feature("Inner")],
2721            )],
2722            vec![],
2723        )];
2724        let config = CodeGenConfig::default();
2725        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2726        assert_eq!(ctx.is_enum_closed(".p.M.Inner"), Some(true));
2727    }
2728
2729    #[test]
2730    fn test_is_enum_closed_unknown_enum_returns_none() {
2731        let files = [editions_file("a.proto", "p", vec![], vec![])];
2732        let config = CodeGenConfig::default();
2733        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
2734        // extern_path or missing enum → None (caller falls back).
2735        assert_eq!(ctx.is_enum_closed(".other.Unknown"), None);
2736    }
2737
2738    #[test]
2739    fn test_for_generate_auto_injects_wkt_mapping() {
2740        // for_generate() must produce the same type_map as generate() uses
2741        // internally — including the auto-injected WKT extern_path.
2742        let ts_msg = DescriptorProto {
2743            name: Some("Timestamp".into()),
2744            ..Default::default()
2745        };
2746        let files = [FileDescriptorProto {
2747            name: Some("google/protobuf/timestamp.proto".into()),
2748            package: Some("google.protobuf".into()),
2749            syntax: Some("proto3".into()),
2750            message_type: vec![ts_msg],
2751            ..Default::default()
2752        }];
2753        let config = CodeGenConfig::default();
2754        // Not generating the WKT file itself → auto-mapping should kick in.
2755        let ctx = CodeGenContext::for_generate(&files, &["other.proto".into()], &config);
2756        assert_eq!(
2757            ctx.rust_type(".google.protobuf.Timestamp"),
2758            Some("::buffa_types::google::protobuf::Timestamp"),
2759            "WKT auto-mapping must be applied via for_generate"
2760        );
2761    }
2762
2763    #[test]
2764    fn test_for_generate_suppresses_wkt_when_generating_wkt() {
2765        // When files_to_generate includes a google.protobuf file (building
2766        // buffa-types itself), the WKT auto-mapping must NOT be applied.
2767        let ts_msg = DescriptorProto {
2768            name: Some("Timestamp".into()),
2769            ..Default::default()
2770        };
2771        let files = [FileDescriptorProto {
2772            name: Some("google/protobuf/timestamp.proto".into()),
2773            package: Some("google.protobuf".into()),
2774            syntax: Some("proto3".into()),
2775            message_type: vec![ts_msg],
2776            ..Default::default()
2777        }];
2778        let config = CodeGenConfig::default();
2779        let ctx = CodeGenContext::for_generate(
2780            &files,
2781            &["google/protobuf/timestamp.proto".into()],
2782            &config,
2783        );
2784        // No extern mapping → local-package path.
2785        assert_eq!(
2786            ctx.rust_type(".google.protobuf.Timestamp"),
2787            Some("google::protobuf::Timestamp")
2788        );
2789    }
2790
2791    // ── matching_attributes tests ──────────────────────────────────────
2792
2793    #[test]
2794    fn test_matching_attributes_catchall() {
2795        // "." matches every type.
2796        let attrs = vec![(".".into(), "#[derive(Foo)]".into())];
2797        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2798        assert!(result.to_string().contains("derive"));
2799    }
2800
2801    #[test]
2802    fn test_matching_attributes_exact_match() {
2803        let attrs = vec![(".my.pkg.MyMessage".into(), "#[derive(Bar)]".into())];
2804        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2805        assert!(result.to_string().contains("derive"));
2806    }
2807
2808    #[test]
2809    fn test_matching_attributes_package_prefix() {
2810        let attrs = vec![(".my.pkg".into(), "#[derive(Baz)]".into())];
2811        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2812        assert!(result.to_string().contains("derive"));
2813    }
2814
2815    #[test]
2816    fn test_matching_attributes_no_partial_segment_match() {
2817        // ".my.pk" must not match ".my.pkg" (partial segment).
2818        let attrs = vec![(".my.pk".into(), "#[derive(Bad)]".into())];
2819        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2820        assert!(result.is_empty());
2821    }
2822
2823    #[test]
2824    fn test_matching_attributes_no_match() {
2825        let attrs = vec![(".other.pkg".into(), "#[derive(Nope)]".into())];
2826        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2827        assert!(result.is_empty());
2828    }
2829
2830    #[test]
2831    fn test_matching_attributes_multiple_accumulate() {
2832        // All matching entries are emitted, not just the first.
2833        let attrs = vec![
2834            (".".into(), "#[derive(A)]".into()),
2835            (".my.pkg".into(), "#[derive(B)]".into()),
2836        ];
2837        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
2838        let s = result.to_string();
2839        assert!(s.contains("A") && s.contains("B"));
2840    }
2841
2842    #[test]
2843    fn test_matching_attributes_invalid_attr_errors() {
2844        // Unparseable attributes surface as a hard error so the user sees
2845        // the problem at build time rather than a silently-dropped attribute.
2846        let attrs = vec![(".".into(), "not valid {{{{".into())];
2847        let err = CodeGenContext::matching_attributes(&attrs, "my.pkg.Msg").unwrap_err();
2848        assert!(matches!(
2849            err,
2850            crate::CodeGenError::InvalidCustomAttribute { .. }
2851        ));
2852    }
2853
2854    #[test]
2855    fn test_matches_proto_prefix_catchall() {
2856        assert!(matches_proto_prefix(".", ".anything.here"));
2857        assert!(matches_proto_prefix(".", "."));
2858    }
2859
2860    #[test]
2861    fn test_matches_proto_prefix_segment_boundary() {
2862        // Segment-aware: ".my.pk" must not match ".my.pkg".
2863        assert!(!matches_proto_prefix(".my.pk", ".my.pkg.Msg"));
2864        // But full-segment prefix match does.
2865        assert!(matches_proto_prefix(".my.pkg", ".my.pkg.Msg"));
2866    }
2867}