Skip to main content

buffa_codegen/
context.rs

1//! Code generation context and descriptor-to-Rust mapping state.
2
3use std::collections::HashMap;
4
5use crate::features::{self, ResolvedFeatures};
6use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
7use crate::oneof::to_snake_case;
8use crate::CodeGenConfig;
9
10/// The single reserved module name under which all ancillary generated types
11/// (views, oneof enums, extensions, `register_types`) live.
12///
13/// See `DESIGN.md` → "Generated code layout" for the full layout. The name
14/// is checked against proto package segments and message-module names by
15/// `validate_file`; a collision is a hard error.
16pub const SENTINEL_MOD: &str = "__buffa";
17
18/// A Rust type path split at the target-package boundary.
19///
20/// Returned by [`CodeGenContext::rust_type_relative_split`]. The full owned
21/// path is `to_package + within_package` (concatenated with `::`); ancillary
22/// kinds insert their `__buffa::<kind>::` prefix between the two halves.
23#[derive(Debug, Clone)]
24pub struct SplitPath {
25    /// Path from the current emission scope to the **target package root**.
26    ///
27    /// One of:
28    /// - empty (same package, nesting 0)
29    /// - `"super::super"` (same package, nesting > 0)
30    /// - `"super::…::other_pkg"` (cross-package local)
31    /// - `"::extern_crate::pkg"` (extern type — absolute, nesting-independent)
32    pub to_package: String,
33    /// Path from the target package root to the type itself
34    /// (e.g. `"Foo"` or `"outer::Inner"`).
35    pub within_package: String,
36    /// `true` when `to_package` is an absolute (`::`/`crate::`) extern path.
37    /// Extern paths don't depend on the caller's nesting depth.
38    pub is_extern: bool,
39}
40
41/// Shared context for a code generation run.
42///
43/// Holds the full set of file descriptors and a mapping from fully-qualified
44/// protobuf type names to their Rust type paths. This is needed because a
45/// field in one `.proto` file may reference a message defined in another.
46pub struct CodeGenContext<'a> {
47    /// All file descriptors (both requested and dependencies).
48    pub files: &'a [FileDescriptorProto],
49    /// Code generation configuration.
50    pub config: &'a CodeGenConfig,
51    /// Map from fully-qualified protobuf name (e.g., ".my.package.MyMessage")
52    /// to Rust type path (e.g., "my::package::MyMessage").
53    ///
54    /// Nested types use module-qualified paths:
55    /// ".pkg.Outer.Inner" → "pkg::outer::Inner" (not "pkg::OuterInner").
56    pub type_map: HashMap<String, String>,
57    /// Map from fully-qualified protobuf name to its proto package.
58    ///
59    /// Used by `rust_type_relative` to compute `super::`-based relative
60    /// paths for cross-package references within the same compilation.
61    package_of: HashMap<String, String>,
62    /// Map from fully-qualified enum name to its resolved `enum_type` feature.
63    ///
64    /// The `enum_type` feature determines whether an enum is OPEN or CLOSED.
65    /// It's resolved from the ENUM's own file → message → enum feature chain,
66    /// NOT from the referencing field's chain. protoc does not propagate
67    /// enum-level `enum_type` into field options (verified 2026-03), so
68    /// callers must look this up via `is_enum_closed`.
69    enum_closedness: HashMap<String, bool>,
70    /// Map from fully-qualified protobuf element name to its source comment.
71    ///
72    /// Keys use dotted FQN form without a leading dot, matching the `proto_fqn`
73    /// values already threaded through codegen: `"pkg.Message"`,
74    /// `"pkg.Message.field_name"`, `"pkg.Enum.VALUE_NAME"`,
75    /// `"pkg.Message.oneof_name"`.
76    ///
77    /// Built by walking each file's descriptor tree alongside its
78    /// `SourceCodeInfo` (which uses index-based paths). This up-front
79    /// translation means codegen call sites can look up comments by the
80    /// proto FQN they already have, rather than threading index-based paths
81    /// through every function signature.
82    comment_map: HashMap<String, String>,
83}
84
85impl<'a> CodeGenContext<'a> {
86    /// Build a context from file descriptors, populating the type map.
87    ///
88    /// `effective_extern_paths` includes both user-provided mappings and any
89    /// auto-injected defaults (e.g., the WKT mapping). These are computed by
90    /// `crate::effective_extern_paths` before calling this constructor.
91    ///
92    /// File-level extern resolution (currently only `descriptor.proto` /
93    /// `compiler/plugin.proto` → `buffa-descriptor`) is **not** applied by
94    /// this constructor — use [`for_generate`](Self::for_generate), which
95    /// computes and passes the file-level mappings, when generating code
96    /// that may reference descriptor types.
97    pub fn new(
98        files: &'a [FileDescriptorProto],
99        config: &'a CodeGenConfig,
100        effective_extern_paths: &[(String, String)],
101    ) -> Self {
102        Self::with_extern_resolution(files, config, effective_extern_paths, &[])
103    }
104
105    /// Build a context with both package-level and file-level extern
106    /// mappings.
107    ///
108    /// `file_extern_paths` maps a `.proto` file's full path (as reported in
109    /// `FileDescriptorProto.name`, e.g. `"google/protobuf/descriptor.proto"`)
110    /// to a Rust module root. It takes priority over package-level
111    /// `effective_extern_paths`, which lets two files in the same proto
112    /// package (`google.protobuf`) resolve to different external crates —
113    /// `descriptor.proto` types to `buffa-descriptor`, every other
114    /// `google.protobuf` WKT to `buffa-types`. Without this split a single
115    /// package-keyed mapping would route everything to one crate or the
116    /// other.
117    ///
118    /// File-level mappings are an internal mechanism used for the
119    /// auto-injected descriptor-types routing. They are not part of the
120    /// public `CodeGenConfig` API; user-facing `extern_path` entries remain
121    /// package-prefix keyed.
122    pub(crate) fn with_extern_resolution(
123        files: &'a [FileDescriptorProto],
124        config: &'a CodeGenConfig,
125        effective_extern_paths: &[(String, String)],
126        file_extern_paths: &[(String, String)],
127    ) -> Self {
128        let mut type_map = HashMap::new();
129        let mut package_of = HashMap::new();
130        let mut enum_closedness = HashMap::new();
131        let mut comment_map = HashMap::new();
132
133        for file in files {
134            comment_map.extend(crate::comments::fqn_comments(file));
135            let package = file.package.as_deref().unwrap_or("");
136            let file_features = features::for_file(file);
137            let proto_prefix = if package.is_empty() {
138                String::from(".")
139            } else {
140                format!(".{}.", package)
141            };
142
143            // Resolution priority: file-level extern → package-level extern →
144            // local module path. The file-level check must come first so two
145            // files in the same proto package can route to different crates
146            // (descriptor.proto → buffa-descriptor, timestamp.proto →
147            // buffa-types).
148            let rust_module = if let Some(rust_root) = file
149                .name
150                .as_deref()
151                .and_then(|n| resolve_file_extern(n, file_extern_paths))
152            {
153                rust_root.to_string()
154            } else if let Some(rust_root) = resolve_extern_prefix(package, effective_extern_paths) {
155                rust_root
156            } else {
157                package.replace('.', "::")
158            };
159
160            // Register top-level messages
161            for msg in &file.message_type {
162                if let Some(name) = &msg.name {
163                    let fqn = format!("{}{}", proto_prefix, name);
164                    let rust_path = if rust_module.is_empty() {
165                        name.clone()
166                    } else {
167                        format!("{}::{}", rust_module, name)
168                    };
169                    type_map.insert(fqn.clone(), rust_path);
170                    package_of.insert(fqn.clone(), package.to_string());
171
172                    // Register nested messages using module-qualified paths.
173                    let snake = to_snake_case(name);
174                    let parent_mod = if rust_module.is_empty() {
175                        snake
176                    } else {
177                        format!("{}::{}", rust_module, snake)
178                    };
179                    register_nested_types(
180                        &mut type_map,
181                        &mut package_of,
182                        package,
183                        &fqn,
184                        &parent_mod,
185                        msg,
186                    );
187                    register_nested_enum_closedness(
188                        &mut enum_closedness,
189                        &fqn,
190                        &file_features,
191                        msg,
192                    );
193                }
194            }
195
196            // Register top-level enums
197            for enum_type in &file.enum_type {
198                if let Some(name) = &enum_type.name {
199                    let fqn = format!("{}{}", proto_prefix, name);
200                    let rust_path = if rust_module.is_empty() {
201                        name.clone()
202                    } else {
203                        format!("{}::{}", rust_module, name)
204                    };
205                    type_map.insert(fqn.clone(), rust_path);
206                    package_of.insert(fqn.clone(), package.to_string());
207                    register_enum_closedness(&mut enum_closedness, &fqn, &file_features, enum_type);
208                }
209            }
210        }
211
212        Self {
213            files,
214            config,
215            type_map,
216            package_of,
217            enum_closedness,
218            comment_map,
219        }
220    }
221
222    /// Build a context matching what [`generate()`](crate::generate) uses
223    /// internally.
224    ///
225    /// Computes effective extern paths (user-provided + auto-injected WKT
226    /// mapping to `buffa-types` + auto-injected `descriptor.proto` /
227    /// `compiler/plugin.proto` file-level mapping to `buffa-descriptor`) and
228    /// builds the type map from them.
229    ///
230    /// Convenience for downstream generators (e.g. `connectrpc-codegen`)
231    /// that emit code alongside buffa's message types and need identical
232    /// type-path resolution. Using this instead of [`new()`](Self::new) +
233    /// manual extern-path computation ensures zero drift with buffa's own
234    /// generation.
235    pub fn for_generate(
236        files: &'a [FileDescriptorProto],
237        files_to_generate: &[String],
238        config: &'a CodeGenConfig,
239    ) -> Self {
240        let paths = crate::effective_extern_paths(files, files_to_generate, config);
241        let file_paths = crate::effective_file_extern_paths(files_to_generate, config);
242        Self::with_extern_resolution(files, config, &paths, &file_paths)
243    }
244
245    /// Look up the Rust type path for a fully-qualified protobuf type name.
246    pub fn rust_type(&self, proto_fqn: &str) -> Option<&str> {
247        self.type_map.get(proto_fqn).map(|s| s.as_str())
248    }
249
250    /// Look up the source comment for a protobuf element by FQN.
251    ///
252    /// `fqn` uses the same dotted form as `proto_fqn` throughout codegen
253    /// (no leading dot). For sub-elements, append the element name:
254    /// - Message: `"pkg.Message"`
255    /// - Field: `"pkg.Message.field_name"`
256    /// - Enum value: `"pkg.Enum.VALUE_NAME"`
257    /// - Oneof: `"pkg.Message.oneof_name"`
258    pub fn comment(&self, fqn: &str) -> Option<&str> {
259        self.comment_map.get(fqn).map(|s| s.as_str())
260    }
261
262    /// Look up whether an enum (by fully-qualified proto name) is closed.
263    ///
264    /// Returns `None` if the enum is not in this compilation set (e.g., an
265    /// extern_path type), in which case callers should fall back to the
266    /// referencing field's feature chain (correct for proto2/proto3 where
267    /// `enum_type` is file-level anyway).
268    pub fn is_enum_closed(&self, proto_fqn: &str) -> Option<bool> {
269        self.enum_closedness.get(proto_fqn).copied()
270    }
271
272    /// Look up the Rust type path relative to the current code generation
273    /// scope.
274    ///
275    /// `current_package` is the proto package (e.g., `"google.protobuf"`).
276    /// `nesting` is the number of message module levels the generated code
277    /// sits inside (0 for struct fields and impls at the package level,
278    /// 1 for oneof enums inside a message module, etc.).
279    ///
280    /// - **Same package**: strips the package prefix and prepends `super::`
281    ///   for each nesting level.
282    /// - **Cross package (local)**: navigates via `super::` to the common
283    ///   ancestor, then descends into the target package. This works
284    ///   regardless of where the module tree is placed in the user's crate.
285    /// - **Cross package (extern)**: returns the absolute extern path as-is.
286    pub fn rust_type_relative(
287        &self,
288        proto_fqn: &str,
289        current_package: &str,
290        nesting: usize,
291    ) -> Option<String> {
292        let full_path = self.type_map.get(proto_fqn)?;
293
294        // Extern types use absolute paths (starting with `::` or `crate::`)
295        // and need no relative resolution — they work from any module position.
296        if full_path.starts_with("::") || full_path.starts_with("crate::") {
297            return Some(full_path.clone());
298        }
299
300        let target_package = self
301            .package_of
302            .get(proto_fqn)
303            .map(|s| s.as_str())
304            .unwrap_or("");
305
306        // Extract the type's path within its package (everything after the
307        // package module prefix).
308        let target_rust_module = target_package.replace('.', "::");
309        let type_suffix = if target_rust_module.is_empty() {
310            full_path.as_str()
311        } else {
312            full_path
313                .strip_prefix(&format!("{}::", target_rust_module))
314                .unwrap_or(full_path)
315        };
316
317        if current_package == target_package {
318            // Same package — just the type suffix, with super:: for nesting.
319            if nesting == 0 {
320                return Some(type_suffix.to_string());
321            }
322            let supers = (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::");
323            return Some(format!("{}::{}", supers, type_suffix));
324        }
325
326        // Cross-package local type: compute a super::-based relative path.
327        let current_parts: Vec<&str> = if current_package.is_empty() {
328            vec![]
329        } else {
330            current_package.split('.').collect()
331        };
332        let target_parts: Vec<&str> = if target_package.is_empty() {
333            vec![]
334        } else {
335            target_package.split('.').collect()
336        };
337
338        // Find the length of the common package prefix.
339        let common_len = current_parts
340            .iter()
341            .zip(&target_parts)
342            .take_while(|(a, b)| a == b)
343            .count();
344
345        // Navigate up: one super:: per remaining current package segment,
346        // plus one per nesting level (message module depth).
347        let up_count = (current_parts.len() - common_len) + nesting;
348
349        // Navigate down: target package segments beyond the common prefix.
350        let down_parts = &target_parts[common_len..];
351
352        let mut segments: Vec<&str> = vec!["super"; up_count];
353        segments.extend_from_slice(down_parts);
354
355        // Append the type's within-package path.
356        let mut result = segments.join("::");
357        if !result.is_empty() {
358            result.push_str("::");
359        }
360        result.push_str(type_suffix);
361
362        Some(result)
363    }
364
365    /// Like [`rust_type_relative`](Self::rust_type_relative) but returns the
366    /// path split at the target-package boundary.
367    ///
368    /// Ancillary kinds (views, oneof enums) live in the `__buffa::<kind>::`
369    /// sub-tree of each package; callers compose the final path as
370    /// `to_package + "::__buffa::" + <kind> + "::" + within_package`.
371    ///
372    /// `nesting` is the **total** module depth of the caller's emission
373    /// scope below the current package root — i.e. message-nesting plus any
374    /// `__buffa::<kind>::` levels the caller is already inside (0 for owned
375    /// types, +2 for `__buffa::view::`, +3 for `__buffa::view::oneof::`).
376    pub fn rust_type_relative_split(
377        &self,
378        proto_fqn: &str,
379        current_package: &str,
380        nesting: usize,
381    ) -> Option<SplitPath> {
382        let full_path = self.type_map.get(proto_fqn)?;
383
384        let target_package = self
385            .package_of
386            .get(proto_fqn)
387            .map(|s| s.as_str())
388            .unwrap_or("");
389
390        // Compute the type's path within its package (everything after the
391        // package module prefix). For extern types the prefix is the
392        // configured rust_module (e.g. `::buffa_types::google::protobuf`),
393        // not the bare dotted package, so derive it the same way `new()`
394        // populated the map.
395        let target_rust_module = if full_path.starts_with("::") || full_path.starts_with("crate::")
396        {
397            // Reconstruct the extern module prefix by stripping the
398            // within-package suffix length. We know the proto FQN's
399            // within-package portion (FQN minus package), so the full_path's
400            // last N segments correspond to it.
401            //
402            // Simpler: re-derive via `resolve_extern_prefix` would need the
403            // original extern_paths list. Instead, compute within-package
404            // from the proto FQN (which we know) and slice full_path.
405            let fqn_no_dot = proto_fqn.strip_prefix('.').unwrap_or(proto_fqn);
406            let within_proto = if target_package.is_empty() {
407                fqn_no_dot
408            } else {
409                fqn_no_dot
410                    .strip_prefix(target_package)
411                    .and_then(|s| s.strip_prefix('.'))
412                    .unwrap_or(fqn_no_dot)
413            };
414            // within_proto is dotted (e.g. "Outer.Inner"); within full_path
415            // it's `outer::Inner` (snake_case modules + final PascalCase).
416            // Count the segments and strip that many from full_path.
417            let within_segs = within_proto.split('.').count();
418            let full_segs: Vec<&str> = full_path.split("::").collect();
419            // Invariant: `full_path` was built by `CodeGenContext::new` as
420            // `<rust_module>::<within>`, so it always has at least
421            // `within_segs` trailing segments. If this fires the type_map
422            // and package_of maps are out of sync.
423            debug_assert!(
424                full_segs.len() >= within_segs,
425                "extern path '{full_path}' has fewer segments than \
426                 within-package proto path '{within_proto}'"
427            );
428            let cut = full_segs.len().saturating_sub(within_segs);
429            full_segs[..cut].join("::")
430        } else {
431            target_package.replace('.', "::")
432        };
433
434        let type_suffix = if target_rust_module.is_empty() {
435            full_path.as_str()
436        } else {
437            full_path
438                .strip_prefix(&format!("{}::", target_rust_module))
439                .unwrap_or(full_path)
440        };
441
442        // Extern: absolute path; nesting irrelevant.
443        if full_path.starts_with("::") || full_path.starts_with("crate::") {
444            return Some(SplitPath {
445                to_package: target_rust_module,
446                within_package: type_suffix.to_string(),
447                is_extern: true,
448            });
449        }
450
451        if current_package == target_package {
452            let to_package = if nesting == 0 {
453                String::new()
454            } else {
455                (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::")
456            };
457            return Some(SplitPath {
458                to_package,
459                within_package: type_suffix.to_string(),
460                is_extern: false,
461            });
462        }
463
464        // Cross-package local.
465        let current_parts: Vec<&str> = if current_package.is_empty() {
466            vec![]
467        } else {
468            current_package.split('.').collect()
469        };
470        let target_parts: Vec<&str> = if target_package.is_empty() {
471            vec![]
472        } else {
473            target_package.split('.').collect()
474        };
475        let common_len = current_parts
476            .iter()
477            .zip(&target_parts)
478            .take_while(|(a, b)| a == b)
479            .count();
480        let up_count = (current_parts.len() - common_len) + nesting;
481        let down_parts = &target_parts[common_len..];
482
483        let mut segments: Vec<&str> = vec!["super"; up_count];
484        segments.extend_from_slice(down_parts);
485
486        Some(SplitPath {
487            to_package: segments.join("::"),
488            within_package: type_suffix.to_string(),
489            is_extern: false,
490        })
491    }
492
493    /// Collect custom attributes matching a fully-qualified proto path.
494    ///
495    /// Returns a `TokenStream` of all `#[...]` attributes whose path prefix
496    /// matches `fqn`. Each attribute string is parsed via `syn::parse_str`
497    /// so the caller can interpolate directly into `quote!`.
498    ///
499    /// `fqn` uses dotted form without a leading dot (e.g., `"my.pkg.MyMessage"`).
500    ///
501    /// # Errors
502    ///
503    /// Returns `CodeGenError::InvalidCustomAttribute` if any matching attribute
504    /// string fails to parse as a valid Rust attribute.
505    pub(crate) fn matching_attributes(
506        attrs: &[(String, String)],
507        fqn: &str,
508    ) -> Result<proc_macro2::TokenStream, crate::CodeGenError> {
509        if attrs.is_empty() {
510            return Ok(proc_macro2::TokenStream::new());
511        }
512        let fqn_dotted = format!(".{fqn}");
513        let mut tokens = proc_macro2::TokenStream::new();
514        for (prefix, attr_str) in attrs {
515            if matches_proto_prefix(prefix, &fqn_dotted) {
516                let parsed =
517                    syn::parse_str::<proc_macro2::TokenStream>(attr_str).map_err(|err| {
518                        crate::CodeGenError::InvalidCustomAttribute {
519                            path: prefix.clone(),
520                            attribute: attr_str.clone(),
521                            detail: err.to_string(),
522                        }
523                    })?;
524                tokens.extend(parsed);
525            }
526        }
527        Ok(tokens)
528    }
529
530    /// Check whether a bytes field at the given proto path should use
531    /// `bytes::Bytes` instead of `Vec<u8>`.
532    ///
533    /// `field_fqn` is the fully-qualified proto field path, e.g.,
534    /// `".my.pkg.MyMessage.data"`. Matches against `config.bytes_fields`
535    /// entries using proto-segment-aware prefix matching: `"."` matches all,
536    /// `".my.pkg"` matches `".my.pkg.Msg.data"` but not `".my.pkgs.X.data"`.
537    pub fn use_bytes_type(&self, field_fqn: &str) -> bool {
538        self.config
539            .bytes_fields
540            .iter()
541            .any(|prefix| matches_proto_prefix(prefix, field_fqn))
542    }
543}
544
545/// Scope-local context for code generation within a message.
546///
547/// Bundles the parameters that are constant within a single message's code
548/// generation scope and change only when recursing into nested messages.
549/// Threading this struct instead of five individual parameters keeps function
550/// signatures short and makes adding new scope-level state a one-field change.
551#[derive(Clone, Copy)]
552pub(crate) struct MessageScope<'a> {
553    /// Global codegen context (descriptors, type map, config).
554    pub ctx: &'a CodeGenContext<'a>,
555    /// Proto package of the file being generated (e.g. `"google.protobuf"`).
556    pub current_package: &'a str,
557    /// Fully-qualified proto name of the current message
558    /// (e.g. `"google.protobuf.Timestamp"`, `"pkg.Outer.Inner"`).
559    pub proto_fqn: &'a str,
560    /// Resolved edition features for this message scope.
561    pub features: &'a ResolvedFeatures,
562    /// Module nesting depth — number of `pub mod` levels the generated code
563    /// sits inside.  Controls the count of `super::` prefixes in type
564    /// references via [`CodeGenContext::rust_type_relative`].
565    pub nesting: usize,
566}
567
568impl<'a> MessageScope<'a> {
569    /// Create a child scope for a nested message (increments nesting by 1).
570    pub fn nested(&self, proto_fqn: &'a str, features: &'a ResolvedFeatures) -> MessageScope<'a> {
571        MessageScope {
572            ctx: self.ctx,
573            current_package: self.current_package,
574            proto_fqn,
575            features,
576            nesting: self.nesting + 1,
577        }
578    }
579}
580
581/// Kind of ancillary tree under the [`SENTINEL_MOD`] module.
582///
583/// `path_segments()` returns the module path *inside* `__buffa::` (not
584/// including the sentinel itself).
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
586pub(crate) enum AncillaryKind {
587    /// `__buffa::oneof::<msg_path>::` — owned oneof enums.
588    Oneof,
589    /// `__buffa::view::<msg_path>::` — message view structs.
590    View,
591    /// `__buffa::view::oneof::<msg_path>::` — view oneof enums.
592    ViewOneof,
593}
594
595impl AncillaryKind {
596    fn path_segments(self) -> &'static [&'static str] {
597        match self {
598            Self::Oneof => &["oneof"],
599            Self::View => &["view"],
600            Self::ViewOneof => &["view", "oneof"],
601        }
602    }
603}
604
605/// Build a token-stream path prefix from an emission scope to an ancillary
606/// kind's location for the **current** message (`proto_fqn`).
607///
608/// Always climbs to the package root via `super::` and re-descends through
609/// `__buffa::<kind>::<msg_path>::` — uniform regardless of where the caller
610/// sits. `from_nesting` is the caller's total module depth below the
611/// package root (message-nesting plus any `__buffa::<kind>::` levels the
612/// caller is already inside).
613///
614/// `proto_fqn` follows the dotless convention used throughout codegen
615/// (e.g. `"google.protobuf.Value"`, not `".google.protobuf.Value"`).
616///
617/// Returned tokens always end with `::` so callers append the type
618/// identifier directly: `quote! { #prefix #ident }`.
619pub(crate) fn ancillary_prefix(
620    kind: AncillaryKind,
621    current_package: &str,
622    proto_fqn: &str,
623    from_nesting: usize,
624) -> proc_macro2::TokenStream {
625    use crate::idents::make_field_ident;
626    use quote::quote;
627
628    debug_assert!(
629        !proto_fqn.starts_with('.'),
630        "ancillary_prefix expects dotless FQN, got {proto_fqn:?}"
631    );
632
633    let mut supers_tokens = proc_macro2::TokenStream::new();
634    for _ in 0..from_nesting {
635        supers_tokens.extend(quote! { super:: });
636    }
637
638    let sentinel = make_field_ident(SENTINEL_MOD);
639    let kind_segs: Vec<_> = kind
640        .path_segments()
641        .iter()
642        .map(|s| make_field_ident(s))
643        .collect();
644
645    // Snake-cased message path within the package (e.g. "outer::inner::").
646    let within_pkg = if current_package.is_empty() {
647        proto_fqn
648    } else {
649        proto_fqn
650            .strip_prefix(current_package)
651            .and_then(|s| s.strip_prefix('.'))
652            .unwrap_or(proto_fqn)
653    };
654    let msg_segs: Vec<_> = within_pkg
655        .split('.')
656        .filter(|s| !s.is_empty())
657        .map(|name| make_field_ident(&to_snake_case(name)))
658        .collect();
659
660    quote! { #supers_tokens #sentinel :: #(#kind_segs ::)* #(#msg_segs ::)* }
661}
662
663/// Proto-segment-aware prefix match: `prefix` matches `fqn_dotted` if
664/// `prefix == "."`, the two are equal, or `fqn_dotted` starts with `prefix`
665/// followed by a `.` boundary. Proto identifiers are ASCII, and `.` is ASCII,
666/// so byte indexing is safe.
667pub(crate) fn matches_proto_prefix(prefix: &str, fqn_dotted: &str) -> bool {
668    prefix == "."
669        || prefix == fqn_dotted
670        || (fqn_dotted.starts_with(prefix)
671            && fqn_dotted.as_bytes().get(prefix.len()) == Some(&b'.'))
672}
673
674/// Look up a file-level extern mapping by exact proto file path.
675///
676/// Unlike [`resolve_extern_prefix`], this does no prefix or package
677/// arithmetic — file-level mappings are auto-injected for a known closed set
678/// of bootstrap protos (`descriptor.proto`, `compiler/plugin.proto`) whose
679/// types live in a different crate (`buffa-descriptor`) than the rest of the
680/// `google.protobuf` package (`buffa-types`). The mapped Rust module is the
681/// root of that file's generated module, with no further suffix.
682fn resolve_file_extern<'p>(
683    file_name: &str,
684    file_extern_paths: &'p [(String, String)],
685) -> Option<&'p str> {
686    file_extern_paths
687        .iter()
688        .find(|(name, _)| name == file_name)
689        .map(|(_, rust)| rust.as_str())
690}
691
692/// Check if a proto package matches any extern_path prefix.
693///
694/// Returns the Rust module path root if matched, including any remaining
695/// package segments converted to `snake_case` modules. For example,
696/// extern_path `(".my", "::my_crate")` with package `"my.sub.pkg"` returns
697/// `"::my_crate::sub::pkg"`.
698///
699/// `pub(crate)`: also called from `crate::effective_file_extern_paths` to
700/// suppress an auto-injected file-level mapping when a user package-level
701/// extern_path already covers that file's package.
702pub(crate) fn resolve_extern_prefix(
703    package: &str,
704    extern_paths: &[(String, String)],
705) -> Option<String> {
706    let dotted = format!(".{}", package);
707
708    // Try longest prefix first so that more specific mappings take priority
709    // over broader ones (e.g., ".my.common" before ".my").
710    let mut best: Option<(&str, &str, usize)> = None;
711
712    for (proto_prefix, rust_prefix) in extern_paths {
713        if dotted == *proto_prefix {
714            // Exact match is always the best.
715            return Some(rust_prefix.clone());
716        }
717        if let Some(rest) = dotted.strip_prefix(proto_prefix.as_str()) {
718            // `"."` is the catch-all root; stripping it leaves no leading dot.
719            if proto_prefix == "." || rest.starts_with('.') {
720                let prefix_len = proto_prefix.len();
721                if best.is_none_or(|(_, _, best_len)| prefix_len > best_len) {
722                    best = Some((proto_prefix, rust_prefix, prefix_len));
723                }
724            }
725        }
726    }
727
728    let (proto_prefix, rust_prefix, _) = best?;
729    let rest = dotted.strip_prefix(proto_prefix)?;
730    let rest = rest.strip_prefix('.').unwrap_or(rest);
731    let suffix = rest
732        .split('.')
733        .map(to_snake_case)
734        .collect::<Vec<_>>()
735        .join("::");
736    Some(format!("{}::{}", rust_prefix, suffix))
737}
738
739/// Recursively register nested messages and enums with module-qualified paths.
740///
741/// Each nested message `Parent.Child` maps to `parent_mod::Child` in Rust,
742/// where `parent_mod` is the snake_case module path of the enclosing message.
743fn register_nested_types(
744    type_map: &mut HashMap<String, String>,
745    package_of: &mut HashMap<String, String>,
746    package: &str,
747    parent_fqn: &str,
748    parent_mod: &str,
749    msg: &crate::generated::descriptor::DescriptorProto,
750) {
751    for nested in &msg.nested_type {
752        if let Some(name) = &nested.name {
753            let fqn = format!("{}.{}", parent_fqn, name);
754            let rust_path = format!("{}::{}", parent_mod, name);
755            type_map.insert(fqn.clone(), rust_path);
756            package_of.insert(fqn.clone(), package.to_string());
757
758            // Recurse: nested-of-nested goes in a deeper module.
759            let child_mod = format!("{}::{}", parent_mod, to_snake_case(name));
760            register_nested_types(type_map, package_of, package, &fqn, &child_mod, nested);
761        }
762    }
763
764    for enum_type in &msg.enum_type {
765        if let Some(name) = &enum_type.name {
766            let fqn = format!("{}.{}", parent_fqn, name);
767            let rust_path = format!("{}::{}", parent_mod, name);
768            type_map.insert(fqn.clone(), rust_path);
769            package_of.insert(fqn, package.to_string());
770        }
771    }
772}
773
774/// Resolve and record whether an enum is closed, given its parent's features.
775fn register_enum_closedness(
776    map: &mut HashMap<String, bool>,
777    fqn: &str,
778    parent_features: &ResolvedFeatures,
779    enum_desc: &EnumDescriptorProto,
780) {
781    let resolved = features::resolve_child(parent_features, features::enum_features(enum_desc));
782    let closed = resolved.enum_type == features::EnumType::Closed;
783    map.insert(fqn.to_string(), closed);
784}
785
786/// Walk nested messages and register all enum closedness, resolving features
787/// through the message hierarchy (file → msg → nested_msg → enum).
788fn register_nested_enum_closedness(
789    map: &mut HashMap<String, bool>,
790    parent_fqn: &str,
791    parent_features: &ResolvedFeatures,
792    msg: &DescriptorProto,
793) {
794    let msg_features = features::resolve_child(parent_features, features::message_features(msg));
795    for enum_type in &msg.enum_type {
796        if let Some(name) = &enum_type.name {
797            let fqn = format!("{}.{}", parent_fqn, name);
798            register_enum_closedness(map, &fqn, &msg_features, enum_type);
799        }
800    }
801    for nested in &msg.nested_type {
802        if let Some(name) = &nested.name {
803            let fqn = format!("{}.{}", parent_fqn, name);
804            register_nested_enum_closedness(map, &fqn, &msg_features, nested);
805        }
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
813
814    fn make_file(
815        name: &str,
816        package: &str,
817        messages: Vec<DescriptorProto>,
818        enums: Vec<EnumDescriptorProto>,
819    ) -> FileDescriptorProto {
820        FileDescriptorProto {
821            name: Some(name.to_string()),
822            package: if package.is_empty() {
823                None
824            } else {
825                Some(package.to_string())
826            },
827            message_type: messages,
828            enum_type: enums,
829            ..Default::default()
830        }
831    }
832
833    fn msg(name: &str) -> DescriptorProto {
834        DescriptorProto {
835            name: Some(name.to_string()),
836            ..Default::default()
837        }
838    }
839
840    fn msg_with_nested(name: &str, nested: Vec<DescriptorProto>) -> DescriptorProto {
841        DescriptorProto {
842            name: Some(name.to_string()),
843            nested_type: nested,
844            ..Default::default()
845        }
846    }
847
848    fn msg_with_nested_and_enums(
849        name: &str,
850        nested: Vec<DescriptorProto>,
851        enums: Vec<EnumDescriptorProto>,
852    ) -> DescriptorProto {
853        DescriptorProto {
854            name: Some(name.to_string()),
855            nested_type: nested,
856            enum_type: enums,
857            ..Default::default()
858        }
859    }
860
861    fn enum_desc(name: &str) -> EnumDescriptorProto {
862        EnumDescriptorProto {
863            name: Some(name.to_string()),
864            ..Default::default()
865        }
866    }
867
868    fn enum_with_closed_feature(name: &str) -> EnumDescriptorProto {
869        use crate::generated::descriptor::{feature_set, EnumOptions, FeatureSet};
870        EnumDescriptorProto {
871            name: Some(name.to_string()),
872            options: buffa::MessageField::some(EnumOptions {
873                features: buffa::MessageField::some(FeatureSet {
874                    enum_type: Some(feature_set::EnumType::CLOSED),
875                    ..Default::default()
876                }),
877                ..Default::default()
878            }),
879            ..Default::default()
880        }
881    }
882
883    fn editions_file(
884        name: &str,
885        package: &str,
886        messages: Vec<DescriptorProto>,
887        enums: Vec<EnumDescriptorProto>,
888    ) -> FileDescriptorProto {
889        use crate::generated::descriptor::Edition;
890        FileDescriptorProto {
891            name: Some(name.to_string()),
892            package: Some(package.to_string()),
893            syntax: Some("editions".to_string()),
894            edition: Some(Edition::EDITION_2023),
895            message_type: messages,
896            enum_type: enums,
897            ..Default::default()
898        }
899    }
900
901    // ── Type registration tests ──────────────────────────────────────────
902
903    #[test]
904    fn test_message_with_package() {
905        let files = [make_file(
906            "test.proto",
907            "my.package",
908            vec![msg("Foo")],
909            vec![],
910        )];
911        let config = CodeGenConfig::default();
912        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
913        assert_eq!(ctx.rust_type(".my.package.Foo"), Some("my::package::Foo"));
914    }
915
916    #[test]
917    fn test_message_no_package() {
918        let files = [make_file("test.proto", "", vec![msg("Bar")], vec![])];
919        let config = CodeGenConfig::default();
920        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
921        assert_eq!(ctx.rust_type(".Bar"), Some("Bar"));
922    }
923
924    #[test]
925    fn test_nested_message_uses_module_path() {
926        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
927        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
928        let config = CodeGenConfig::default();
929        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
930        assert_eq!(ctx.rust_type(".pkg.Outer"), Some("pkg::Outer"));
931        // Nested types use module-qualified paths.
932        assert_eq!(ctx.rust_type(".pkg.Outer.Inner"), Some("pkg::outer::Inner"));
933    }
934
935    #[test]
936    fn test_nested_message_no_package() {
937        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
938        let files = [make_file("test.proto", "", vec![outer], vec![])];
939        let config = CodeGenConfig::default();
940        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
941        assert_eq!(ctx.rust_type(".Outer"), Some("Outer"));
942        assert_eq!(ctx.rust_type(".Outer.Inner"), Some("outer::Inner"));
943    }
944
945    #[test]
946    fn test_deeply_nested_message() {
947        let deep = msg_with_nested("A", vec![msg_with_nested("B", vec![msg("C")])]);
948        let files = [make_file("test.proto", "pkg", vec![deep], vec![])];
949        let config = CodeGenConfig::default();
950        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
951        assert_eq!(ctx.rust_type(".pkg.A"), Some("pkg::A"));
952        assert_eq!(ctx.rust_type(".pkg.A.B"), Some("pkg::a::B"));
953        assert_eq!(ctx.rust_type(".pkg.A.B.C"), Some("pkg::a::b::C"));
954    }
955
956    #[test]
957    fn test_nested_enum_uses_module_path() {
958        let outer = msg_with_nested_and_enums("Outer", vec![], vec![enum_desc("Status")]);
959        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
960        let config = CodeGenConfig::default();
961        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
962        assert_eq!(
963            ctx.rust_type(".pkg.Outer.Status"),
964            Some("pkg::outer::Status")
965        );
966    }
967
968    #[test]
969    fn test_top_level_enum() {
970        let files = [make_file(
971            "test.proto",
972            "pkg",
973            vec![],
974            vec![enum_desc("Status")],
975        )];
976        let config = CodeGenConfig::default();
977        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
978        assert_eq!(ctx.rust_type(".pkg.Status"), Some("pkg::Status"));
979    }
980
981    #[test]
982    fn test_same_named_nested_types_in_different_parents_are_distinct() {
983        let outer1 = msg_with_nested("Outer1", vec![msg("Inner")]);
984        let outer2 = msg_with_nested("Outer2", vec![msg("Inner")]);
985        let files = [make_file("a.proto", "pkg", vec![outer1, outer2], vec![])];
986        let config = CodeGenConfig::default();
987        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
988        // Different parent modules make them distinct.
989        assert_eq!(
990            ctx.rust_type(".pkg.Outer1.Inner"),
991            Some("pkg::outer1::Inner")
992        );
993        assert_eq!(
994            ctx.rust_type(".pkg.Outer2.Inner"),
995            Some("pkg::outer2::Inner")
996        );
997        assert_ne!(
998            ctx.rust_type(".pkg.Outer1.Inner"),
999            ctx.rust_type(".pkg.Outer2.Inner")
1000        );
1001    }
1002
1003    #[test]
1004    fn test_multiple_files() {
1005        let files = [
1006            make_file("a.proto", "ns.a", vec![msg("MsgA")], vec![]),
1007            make_file("b.proto", "ns.b", vec![msg("MsgB")], vec![]),
1008        ];
1009        let config = CodeGenConfig::default();
1010        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1011        assert_eq!(ctx.rust_type(".ns.a.MsgA"), Some("ns::a::MsgA"));
1012        assert_eq!(ctx.rust_type(".ns.b.MsgB"), Some("ns::b::MsgB"));
1013    }
1014
1015    #[test]
1016    fn test_keyword_package_segment_in_type_map() {
1017        // Proto package `google.type` — the type map stores plain string paths.
1018        // Keyword escaping happens at the token level, not in the type map.
1019        let files = [make_file(
1020            "latlng.proto",
1021            "google.type",
1022            vec![msg("LatLng")],
1023            vec![],
1024        )];
1025        let config = CodeGenConfig::default();
1026        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1027        assert_eq!(
1028            ctx.rust_type(".google.type.LatLng"),
1029            Some("google::type::LatLng")
1030        );
1031    }
1032
1033    #[test]
1034    fn test_keyword_package_relative_same_package() {
1035        let files = [make_file(
1036            "latlng.proto",
1037            "google.type",
1038            vec![msg("LatLng"), msg("Expr")],
1039            vec![],
1040        )];
1041        let config = CodeGenConfig::default();
1042        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1043        // Same-package reference: just the type name (no module prefix).
1044        assert_eq!(
1045            ctx.rust_type_relative(".google.type.LatLng", "google.type", 0),
1046            Some("LatLng".into())
1047        );
1048    }
1049
1050    #[test]
1051    fn test_keyword_package_cross_package() {
1052        let files = [
1053            make_file("latlng.proto", "google.type", vec![msg("LatLng")], vec![]),
1054            make_file("svc.proto", "google.cloud", vec![msg("Service")], vec![]),
1055        ];
1056        let config = CodeGenConfig::default();
1057        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1058        // Cross-package: relative path via super:: (keyword escaping at token level).
1059        // From google.cloud, go up one (past "cloud"), then into "type".
1060        assert_eq!(
1061            ctx.rust_type_relative(".google.type.LatLng", "google.cloud", 0),
1062            Some("super::type::LatLng".into())
1063        );
1064    }
1065
1066    #[test]
1067    fn test_keyword_nested_message_module() {
1068        // Message named "Type" → module "type" in type map.
1069        let outer = msg_with_nested("Type", vec![msg("Inner")]);
1070        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1071        let config = CodeGenConfig::default();
1072        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1073        assert_eq!(ctx.rust_type(".pkg.Type"), Some("pkg::Type"));
1074        assert_eq!(ctx.rust_type(".pkg.Type.Inner"), Some("pkg::type::Inner"));
1075    }
1076
1077    #[test]
1078    fn test_unknown_type_returns_none() {
1079        let files = [make_file("test.proto", "pkg", vec![msg("Foo")], vec![])];
1080        let config = CodeGenConfig::default();
1081        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1082        assert_eq!(ctx.rust_type(".pkg.Unknown"), None);
1083    }
1084
1085    // ── Relative type resolution tests ───────────────────────────────────
1086
1087    #[test]
1088    fn test_relative_same_package_top_level() {
1089        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
1090        let config = CodeGenConfig::default();
1091        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1092        // From top-level in same package: just the type name.
1093        assert_eq!(
1094            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
1095            Some("Foo".into())
1096        );
1097    }
1098
1099    #[test]
1100    fn test_relative_cross_package() {
1101        let files = [
1102            make_file("a.proto", "pkg_a", vec![msg("Foo")], vec![]),
1103            make_file("b.proto", "pkg_b", vec![msg("Bar")], vec![]),
1104        ];
1105        let config = CodeGenConfig::default();
1106        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1107        // Cross-package: relative via super:: (up one from pkg_b, into pkg_a).
1108        assert_eq!(
1109            ctx.rust_type_relative(".pkg_a.Foo", "pkg_b", 0),
1110            Some("super::pkg_a::Foo".into())
1111        );
1112    }
1113
1114    #[test]
1115    fn test_relative_no_package() {
1116        let files = [make_file("a.proto", "", vec![msg("Foo")], vec![])];
1117        let config = CodeGenConfig::default();
1118        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1119        assert_eq!(ctx.rust_type_relative(".Foo", "", 0), Some("Foo".into()));
1120    }
1121
1122    #[test]
1123    fn test_relative_unknown_returns_none() {
1124        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
1125        let config = CodeGenConfig::default();
1126        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1127        assert_eq!(ctx.rust_type_relative(".pkg.Unknown", "pkg", 0), None);
1128    }
1129
1130    #[test]
1131    fn test_relative_dotted_package() {
1132        let files = [make_file("a.proto", "my.pkg", vec![msg("Foo")], vec![])];
1133        let config = CodeGenConfig::default();
1134        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1135        assert_eq!(
1136            ctx.rust_type_relative(".my.pkg.Foo", "my.pkg", 0),
1137            Some("Foo".into())
1138        );
1139    }
1140
1141    #[test]
1142    fn test_relative_cross_dotted_packages() {
1143        let files = [
1144            make_file(
1145                "timestamp.proto",
1146                "google.protobuf",
1147                vec![msg("Timestamp")],
1148                vec![],
1149            ),
1150            make_file(
1151                "test.proto",
1152                "protobuf_test_messages.proto3",
1153                vec![msg("TestAllTypesProto3")],
1154                vec![],
1155            ),
1156        ];
1157        let config = CodeGenConfig::default();
1158        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1159
1160        // Cross-package: relative via super:: (no common prefix, up 2 levels).
1161        assert_eq!(
1162            ctx.rust_type_relative(
1163                ".google.protobuf.Timestamp",
1164                "protobuf_test_messages.proto3",
1165                0,
1166            ),
1167            Some("super::super::google::protobuf::Timestamp".into())
1168        );
1169    }
1170
1171    #[test]
1172    fn test_relative_nested_type_from_same_package() {
1173        // Referencing Outer.Inner from the same package.
1174        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
1175        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
1176        let config = CodeGenConfig::default();
1177        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1178
1179        // Same package: strips the package prefix, keeps module path.
1180        assert_eq!(
1181            ctx.rust_type_relative(".pkg.Outer.Inner", "pkg", 0),
1182            Some("outer::Inner".into())
1183        );
1184    }
1185
1186    #[test]
1187    fn test_relative_shared_prefix_not_confused() {
1188        let files = [
1189            make_file("ab.proto", "a.b", vec![msg("Msg1")], vec![]),
1190            make_file("abc.proto", "a.bc", vec![msg("Msg2")], vec![]),
1191        ];
1192        let config = CodeGenConfig::default();
1193        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1194
1195        // `a.b.Msg1` from `a.bc` context: common prefix "a", up 1, into "b".
1196        assert_eq!(
1197            ctx.rust_type_relative(".a.b.Msg1", "a.bc", 0),
1198            Some("super::b::Msg1".into())
1199        );
1200        // `a.bc.Msg2` from `a.b` context: common prefix "a", up 1, into "bc".
1201        assert_eq!(
1202            ctx.rust_type_relative(".a.bc.Msg2", "a.b", 0),
1203            Some("super::bc::Msg2".into())
1204        );
1205    }
1206
1207    // ── Nesting depth tests ────────────────────────────────────────────
1208
1209    #[test]
1210    fn test_relative_cross_package_nesting_1() {
1211        // Simulates a nested message (inside a `pub mod`) referencing a type
1212        // from a sibling package. E.g., account.business.admin.v1 nested msg
1213        // referencing account.business.v1.Business.Status.
1214        let outer = msg_with_nested_and_enums("Business", vec![], vec![enum_desc("Status")]);
1215        let files = [
1216            make_file("admin.proto", "a.b.admin.v1", vec![msg("Svc")], vec![]),
1217            make_file("biz.proto", "a.b.v1", vec![outer], vec![]),
1218        ];
1219        let config = CodeGenConfig::default();
1220        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1221
1222        // nesting=0 (top-level struct in admin.v1): up 2 (v1→admin), into v1
1223        assert_eq!(
1224            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 0),
1225            Some("super::super::v1::business::Status".into())
1226        );
1227        // nesting=1 (inside a nested message module): one extra super::
1228        assert_eq!(
1229            ctx.rust_type_relative(".a.b.v1.Business.Status", "a.b.admin.v1", 1),
1230            Some("super::super::super::v1::business::Status".into())
1231        );
1232    }
1233
1234    #[test]
1235    fn test_relative_same_package_nesting_1() {
1236        // Nested message referencing a sibling type in the same package.
1237        let files = [make_file(
1238            "test.proto",
1239            "pkg",
1240            vec![msg("Foo"), msg("Bar")],
1241            vec![],
1242        )];
1243        let config = CodeGenConfig::default();
1244        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1245
1246        // nesting=0: same package, just the name
1247        assert_eq!(
1248            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
1249            Some("Foo".into())
1250        );
1251        // nesting=1: inside a message module, needs one super::
1252        assert_eq!(
1253            ctx.rust_type_relative(".pkg.Foo", "pkg", 1),
1254            Some("super::Foo".into())
1255        );
1256        // nesting=2: doubly nested
1257        assert_eq!(
1258            ctx.rust_type_relative(".pkg.Foo", "pkg", 2),
1259            Some("super::super::Foo".into())
1260        );
1261    }
1262
1263    // ── Extern path tests ─────────────────────────────────────────────
1264
1265    #[test]
1266    fn test_resolve_file_extern_exact_match_only() {
1267        let mappings = [(
1268            "google/protobuf/descriptor.proto".to_string(),
1269            "::buffa_descriptor::generated::descriptor".to_string(),
1270        )];
1271        // Exact file path matches.
1272        assert_eq!(
1273            resolve_file_extern("google/protobuf/descriptor.proto", &mappings),
1274            Some("::buffa_descriptor::generated::descriptor"),
1275        );
1276        // No prefix matching — a sibling file in the same directory does
1277        // not match (its types live in a different generated module).
1278        assert_eq!(
1279            resolve_file_extern("google/protobuf/timestamp.proto", &mappings),
1280            None,
1281        );
1282        // No suffix matching either.
1283        assert_eq!(
1284            resolve_file_extern("vendor/google/protobuf/descriptor.proto", &mappings),
1285            None,
1286        );
1287    }
1288
1289    #[test]
1290    fn test_resolve_extern_prefix_exact_match() {
1291        let result = resolve_extern_prefix(
1292            "my.common",
1293            &[(".my.common".into(), "::common_protos".into())],
1294        );
1295        assert_eq!(result, Some("::common_protos".into()));
1296    }
1297
1298    #[test]
1299    fn test_resolve_extern_prefix_sub_package() {
1300        let result = resolve_extern_prefix(
1301            "my.common.sub",
1302            &[(".my.common".into(), "::common_protos".into())],
1303        );
1304        assert_eq!(result, Some("::common_protos::sub".into()));
1305    }
1306
1307    #[test]
1308    fn test_resolve_extern_prefix_no_match() {
1309        let result = resolve_extern_prefix(
1310            "other.pkg",
1311            &[(".my.common".into(), "::common_protos".into())],
1312        );
1313        assert_eq!(result, None);
1314    }
1315
1316    #[test]
1317    fn test_resolve_extern_prefix_partial_name_no_match() {
1318        // ".my.common" should not match ".my.commonext"
1319        let result = resolve_extern_prefix(
1320            "my.commonext",
1321            &[(".my.common".into(), "::common_protos".into())],
1322        );
1323        assert_eq!(result, None);
1324    }
1325
1326    #[test]
1327    fn test_resolve_extern_prefix_longest_match_wins() {
1328        // When multiple prefixes match, the longest one should win.
1329        let result = resolve_extern_prefix(
1330            "my.common.sub",
1331            &[
1332                (".my".into(), "::crate_a".into()),
1333                (".my.common".into(), "::crate_b".into()),
1334            ],
1335        );
1336        assert_eq!(result, Some("::crate_b::sub".into()));
1337    }
1338
1339    #[test]
1340    fn test_resolve_extern_prefix_catchall() {
1341        let result = resolve_extern_prefix("greet.v1", &[(".".into(), "crate::proto".into())]);
1342        assert_eq!(result, Some("crate::proto::greet::v1".into()));
1343    }
1344
1345    #[test]
1346    fn test_resolve_extern_prefix_catchall_empty_pkg() {
1347        // Empty package with `.` catch-all hits the exact-match branch
1348        // (dotted == "." == proto_prefix) and returns the root as-is.
1349        let result = resolve_extern_prefix("", &[(".".into(), "crate::proto".into())]);
1350        assert_eq!(result, Some("crate::proto".into()));
1351    }
1352
1353    #[test]
1354    fn test_resolve_extern_prefix_catchall_longest_wins() {
1355        // `.` catch-all is the shortest possible prefix; any more-specific
1356        // mapping (including the auto-injected WKT mapping) takes priority.
1357        let result = resolve_extern_prefix(
1358            "google.protobuf",
1359            &[
1360                (".".into(), "crate::proto".into()),
1361                (
1362                    ".google.protobuf".into(),
1363                    "::buffa_types::google::protobuf".into(),
1364                ),
1365            ],
1366        );
1367        assert_eq!(result, Some("::buffa_types::google::protobuf".into()));
1368    }
1369
1370    #[test]
1371    fn test_resolve_extern_prefix_catchall_keyword_package() {
1372        // Keyword segments stay unescaped at the string level; escaping to
1373        // `r#type` happens later in `idents::rust_path_to_tokens`.
1374        let result = resolve_extern_prefix("google.type", &[(".".into(), "crate::proto".into())]);
1375        assert_eq!(result, Some("crate::proto::google::type".into()));
1376    }
1377
1378    // ── rust_type_relative_split — extern branch ────────────────────────
1379
1380    #[test]
1381    fn test_split_extern_top_level() {
1382        let outer = msg_with_nested("Value", vec![msg("Inner")]);
1383        let files = [make_file(
1384            "struct.proto",
1385            "google.protobuf",
1386            vec![outer],
1387            vec![],
1388        )];
1389        let config = CodeGenConfig::default();
1390        let extern_paths = vec![(
1391            ".google.protobuf".into(),
1392            "::buffa_types::google::protobuf".into(),
1393        )];
1394        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
1395
1396        let split = ctx
1397            .rust_type_relative_split(".google.protobuf.Value", "my.pkg", 3)
1398            .expect("type resolves");
1399        assert!(split.is_extern);
1400        // Extern path is absolute → nesting irrelevant.
1401        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
1402        assert_eq!(split.within_package, "Value");
1403    }
1404
1405    #[test]
1406    fn test_split_extern_nested_type() {
1407        // Nested `.google.protobuf.Value.Inner` →
1408        // extern path `::buffa_types::google::protobuf::value::Inner`.
1409        // Segment-count slice: 2 within-package segments → cut after the
1410        // extern module prefix.
1411        let outer = msg_with_nested("Value", vec![msg("Inner")]);
1412        let files = [make_file(
1413            "struct.proto",
1414            "google.protobuf",
1415            vec![outer],
1416            vec![],
1417        )];
1418        let config = CodeGenConfig::default();
1419        let extern_paths = vec![(
1420            ".google.protobuf".into(),
1421            "::buffa_types::google::protobuf".into(),
1422        )];
1423        let ctx = CodeGenContext::new(&files, &config, &extern_paths);
1424
1425        let split = ctx
1426            .rust_type_relative_split(".google.protobuf.Value.Inner", "my.pkg", 0)
1427            .expect("nested type resolves");
1428        assert!(split.is_extern);
1429        assert_eq!(split.to_package, "::buffa_types::google::protobuf");
1430        assert_eq!(split.within_package, "value::Inner");
1431    }
1432
1433    #[test]
1434    fn test_extern_path_top_level_message() {
1435        let files = [make_file(
1436            "common.proto",
1437            "my.common",
1438            vec![msg("SharedMsg")],
1439            vec![],
1440        )];
1441        let config = CodeGenConfig {
1442            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1443            ..Default::default()
1444        };
1445        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1446        assert_eq!(
1447            ctx.rust_type(".my.common.SharedMsg"),
1448            Some("::common_protos::SharedMsg")
1449        );
1450    }
1451
1452    #[test]
1453    fn test_extern_path_nested_message() {
1454        let files = [make_file(
1455            "common.proto",
1456            "my.common",
1457            vec![msg_with_nested("Outer", vec![msg("Inner")])],
1458            vec![],
1459        )];
1460        let config = CodeGenConfig {
1461            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1462            ..Default::default()
1463        };
1464        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1465        assert_eq!(
1466            ctx.rust_type(".my.common.Outer"),
1467            Some("::common_protos::Outer")
1468        );
1469        assert_eq!(
1470            ctx.rust_type(".my.common.Outer.Inner"),
1471            Some("::common_protos::outer::Inner")
1472        );
1473    }
1474
1475    #[test]
1476    fn test_extern_path_enum() {
1477        let files = [make_file(
1478            "common.proto",
1479            "my.common",
1480            vec![],
1481            vec![enum_desc("Status")],
1482        )];
1483        let config = CodeGenConfig {
1484            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1485            ..Default::default()
1486        };
1487        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1488        assert_eq!(
1489            ctx.rust_type(".my.common.Status"),
1490            Some("::common_protos::Status")
1491        );
1492    }
1493
1494    #[test]
1495    fn test_extern_path_does_not_affect_other_packages() {
1496        let files = [
1497            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
1498            make_file(
1499                "service.proto",
1500                "my.service",
1501                vec![msg("MyService")],
1502                vec![],
1503            ),
1504        ];
1505        let config = CodeGenConfig {
1506            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1507            ..Default::default()
1508        };
1509        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1510        // Extern type uses absolute path.
1511        assert_eq!(
1512            ctx.rust_type(".my.common.SharedMsg"),
1513            Some("::common_protos::SharedMsg")
1514        );
1515        // Non-extern type uses normal package-derived path.
1516        assert_eq!(
1517            ctx.rust_type(".my.service.MyService"),
1518            Some("my::service::MyService")
1519        );
1520    }
1521
1522    #[test]
1523    fn test_extern_path_relative_returns_absolute() {
1524        // When an extern type is referenced from another package,
1525        // rust_type_relative should return the full absolute path.
1526        let files = [
1527            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
1528            make_file(
1529                "service.proto",
1530                "my.service",
1531                vec![msg("MyService")],
1532                vec![],
1533            ),
1534        ];
1535        let config = CodeGenConfig {
1536            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1537            ..Default::default()
1538        };
1539        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1540        // Cross-package reference to extern type: absolute path.
1541        assert_eq!(
1542            ctx.rust_type_relative(".my.common.SharedMsg", "my.service", 0),
1543            Some("::common_protos::SharedMsg".into())
1544        );
1545    }
1546
1547    // ── is_enum_closed tests ──────────────────────────────────────────────
1548
1549    #[test]
1550    fn test_is_enum_closed_proto3_default_open() {
1551        let files = [make_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1552        let config = CodeGenConfig::default();
1553        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1554        // proto3 default (make_file has no syntax = proto2/implicit)
1555        // actually make_file doesn't set syntax, so it's proto2 default...
1556        // proto2 default is CLOSED.
1557        assert_eq!(ctx.is_enum_closed(".p.E"), Some(true));
1558    }
1559
1560    #[test]
1561    fn test_is_enum_closed_editions_default_open() {
1562        let files = [editions_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1563        let config = CodeGenConfig::default();
1564        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1565        // Edition 2023 default is OPEN.
1566        assert_eq!(ctx.is_enum_closed(".p.E"), Some(false));
1567    }
1568
1569    #[test]
1570    fn test_is_enum_closed_per_enum_override() {
1571        // This is THE bug: enum with `option features.enum_type = CLOSED`
1572        // in an otherwise-open editions file must be detected as closed.
1573        let files = [editions_file(
1574            "a.proto",
1575            "p",
1576            vec![],
1577            vec![enum_desc("Open"), enum_with_closed_feature("Closed")],
1578        )];
1579        let config = CodeGenConfig::default();
1580        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1581        assert_eq!(ctx.is_enum_closed(".p.Open"), Some(false));
1582        assert_eq!(ctx.is_enum_closed(".p.Closed"), Some(true));
1583    }
1584
1585    #[test]
1586    fn test_is_enum_closed_nested_per_enum_override() {
1587        // Feature resolution through file → message → enum.
1588        let files = [editions_file(
1589            "a.proto",
1590            "p",
1591            vec![msg_with_nested_and_enums(
1592                "M",
1593                vec![],
1594                vec![enum_with_closed_feature("Inner")],
1595            )],
1596            vec![],
1597        )];
1598        let config = CodeGenConfig::default();
1599        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1600        assert_eq!(ctx.is_enum_closed(".p.M.Inner"), Some(true));
1601    }
1602
1603    #[test]
1604    fn test_is_enum_closed_unknown_enum_returns_none() {
1605        let files = [editions_file("a.proto", "p", vec![], vec![])];
1606        let config = CodeGenConfig::default();
1607        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1608        // extern_path or missing enum → None (caller falls back).
1609        assert_eq!(ctx.is_enum_closed(".other.Unknown"), None);
1610    }
1611
1612    #[test]
1613    fn test_for_generate_auto_injects_wkt_mapping() {
1614        // for_generate() must produce the same type_map as generate() uses
1615        // internally — including the auto-injected WKT extern_path.
1616        let ts_msg = DescriptorProto {
1617            name: Some("Timestamp".into()),
1618            ..Default::default()
1619        };
1620        let files = [FileDescriptorProto {
1621            name: Some("google/protobuf/timestamp.proto".into()),
1622            package: Some("google.protobuf".into()),
1623            syntax: Some("proto3".into()),
1624            message_type: vec![ts_msg],
1625            ..Default::default()
1626        }];
1627        let config = CodeGenConfig::default();
1628        // Not generating the WKT file itself → auto-mapping should kick in.
1629        let ctx = CodeGenContext::for_generate(&files, &["other.proto".into()], &config);
1630        assert_eq!(
1631            ctx.rust_type(".google.protobuf.Timestamp"),
1632            Some("::buffa_types::google::protobuf::Timestamp"),
1633            "WKT auto-mapping must be applied via for_generate"
1634        );
1635    }
1636
1637    #[test]
1638    fn test_for_generate_suppresses_wkt_when_generating_wkt() {
1639        // When files_to_generate includes a google.protobuf file (building
1640        // buffa-types itself), the WKT auto-mapping must NOT be applied.
1641        let ts_msg = DescriptorProto {
1642            name: Some("Timestamp".into()),
1643            ..Default::default()
1644        };
1645        let files = [FileDescriptorProto {
1646            name: Some("google/protobuf/timestamp.proto".into()),
1647            package: Some("google.protobuf".into()),
1648            syntax: Some("proto3".into()),
1649            message_type: vec![ts_msg],
1650            ..Default::default()
1651        }];
1652        let config = CodeGenConfig::default();
1653        let ctx = CodeGenContext::for_generate(
1654            &files,
1655            &["google/protobuf/timestamp.proto".into()],
1656            &config,
1657        );
1658        // No extern mapping → local-package path.
1659        assert_eq!(
1660            ctx.rust_type(".google.protobuf.Timestamp"),
1661            Some("google::protobuf::Timestamp")
1662        );
1663    }
1664
1665    // ── matching_attributes tests ──────────────────────────────────────
1666
1667    #[test]
1668    fn test_matching_attributes_catchall() {
1669        // "." matches every type.
1670        let attrs = vec![(".".into(), "#[derive(Foo)]".into())];
1671        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1672        assert!(result.to_string().contains("derive"));
1673    }
1674
1675    #[test]
1676    fn test_matching_attributes_exact_match() {
1677        let attrs = vec![(".my.pkg.MyMessage".into(), "#[derive(Bar)]".into())];
1678        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1679        assert!(result.to_string().contains("derive"));
1680    }
1681
1682    #[test]
1683    fn test_matching_attributes_package_prefix() {
1684        let attrs = vec![(".my.pkg".into(), "#[derive(Baz)]".into())];
1685        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1686        assert!(result.to_string().contains("derive"));
1687    }
1688
1689    #[test]
1690    fn test_matching_attributes_no_partial_segment_match() {
1691        // ".my.pk" must not match ".my.pkg" (partial segment).
1692        let attrs = vec![(".my.pk".into(), "#[derive(Bad)]".into())];
1693        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1694        assert!(result.is_empty());
1695    }
1696
1697    #[test]
1698    fn test_matching_attributes_no_match() {
1699        let attrs = vec![(".other.pkg".into(), "#[derive(Nope)]".into())];
1700        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1701        assert!(result.is_empty());
1702    }
1703
1704    #[test]
1705    fn test_matching_attributes_multiple_accumulate() {
1706        // All matching entries are emitted, not just the first.
1707        let attrs = vec![
1708            (".".into(), "#[derive(A)]".into()),
1709            (".my.pkg".into(), "#[derive(B)]".into()),
1710        ];
1711        let result = CodeGenContext::matching_attributes(&attrs, "my.pkg.MyMessage").unwrap();
1712        let s = result.to_string();
1713        assert!(s.contains("A") && s.contains("B"));
1714    }
1715
1716    #[test]
1717    fn test_matching_attributes_invalid_attr_errors() {
1718        // Unparseable attributes surface as a hard error so the user sees
1719        // the problem at build time rather than a silently-dropped attribute.
1720        let attrs = vec![(".".into(), "not valid {{{{".into())];
1721        let err = CodeGenContext::matching_attributes(&attrs, "my.pkg.Msg").unwrap_err();
1722        assert!(matches!(
1723            err,
1724            crate::CodeGenError::InvalidCustomAttribute { .. }
1725        ));
1726    }
1727
1728    #[test]
1729    fn test_matches_proto_prefix_catchall() {
1730        assert!(matches_proto_prefix(".", ".anything.here"));
1731        assert!(matches_proto_prefix(".", "."));
1732    }
1733
1734    #[test]
1735    fn test_matches_proto_prefix_segment_boundary() {
1736        // Segment-aware: ".my.pk" must not match ".my.pkg".
1737        assert!(!matches_proto_prefix(".my.pk", ".my.pkg.Msg"));
1738        // But full-segment prefix match does.
1739        assert!(matches_proto_prefix(".my.pkg", ".my.pkg.Msg"));
1740    }
1741}