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/// Shared context for a code generation run.
11///
12/// Holds the full set of file descriptors and a mapping from fully-qualified
13/// protobuf type names to their Rust type paths. This is needed because a
14/// field in one `.proto` file may reference a message defined in another.
15pub struct CodeGenContext<'a> {
16    /// All file descriptors (both requested and dependencies).
17    pub files: &'a [FileDescriptorProto],
18    /// Code generation configuration.
19    pub config: &'a CodeGenConfig,
20    /// Map from fully-qualified protobuf name (e.g., ".my.package.MyMessage")
21    /// to Rust type path (e.g., "my::package::MyMessage").
22    ///
23    /// Nested types use module-qualified paths:
24    /// ".pkg.Outer.Inner" → "pkg::outer::Inner" (not "pkg::OuterInner").
25    pub type_map: HashMap<String, String>,
26    /// Map from fully-qualified protobuf name to its proto package.
27    ///
28    /// Used by `rust_type_relative` to compute `super::`-based relative
29    /// paths for cross-package references within the same compilation.
30    package_of: HashMap<String, String>,
31    /// Map from fully-qualified enum name to its resolved `enum_type` feature.
32    ///
33    /// The `enum_type` feature determines whether an enum is OPEN or CLOSED.
34    /// It's resolved from the ENUM's own file → message → enum feature chain,
35    /// NOT from the referencing field's chain. protoc does not propagate
36    /// enum-level `enum_type` into field options (verified 2026-03), so
37    /// callers must look this up via `is_enum_closed`.
38    enum_closedness: HashMap<String, bool>,
39    /// Map from fully-qualified protobuf element name to its source comment.
40    ///
41    /// Keys use dotted FQN form without a leading dot, matching the `proto_fqn`
42    /// values already threaded through codegen: `"pkg.Message"`,
43    /// `"pkg.Message.field_name"`, `"pkg.Enum.VALUE_NAME"`,
44    /// `"pkg.Message.oneof_name"`.
45    ///
46    /// Built by walking each file's descriptor tree alongside its
47    /// `SourceCodeInfo` (which uses index-based paths). This up-front
48    /// translation means codegen call sites can look up comments by the
49    /// proto FQN they already have, rather than threading index-based paths
50    /// through every function signature.
51    comment_map: HashMap<String, String>,
52}
53
54impl<'a> CodeGenContext<'a> {
55    /// Build a context from file descriptors, populating the type map.
56    ///
57    /// `effective_extern_paths` includes both user-provided mappings and any
58    /// auto-injected defaults (e.g., the WKT mapping). These are computed by
59    /// `crate::effective_extern_paths` before calling this constructor.
60    pub fn new(
61        files: &'a [FileDescriptorProto],
62        config: &'a CodeGenConfig,
63        effective_extern_paths: &[(String, String)],
64    ) -> Self {
65        let mut type_map = HashMap::new();
66        let mut package_of = HashMap::new();
67        let mut enum_closedness = HashMap::new();
68        let mut comment_map = HashMap::new();
69
70        for file in files {
71            comment_map.extend(crate::comments::fqn_comments(file));
72            let package = file.package.as_deref().unwrap_or("");
73            let file_features = features::for_file(file);
74            let proto_prefix = if package.is_empty() {
75                String::from(".")
76            } else {
77                format!(".{}.", package)
78            };
79
80            // Check if this file's package matches an extern_path.
81            // If so, types are registered with the extern Rust path prefix.
82            let rust_module =
83                if let Some(rust_root) = resolve_extern_prefix(package, effective_extern_paths) {
84                    rust_root
85                } else {
86                    package.replace('.', "::")
87                };
88
89            // Register top-level messages
90            for msg in &file.message_type {
91                if let Some(name) = &msg.name {
92                    let fqn = format!("{}{}", proto_prefix, name);
93                    let rust_path = if rust_module.is_empty() {
94                        name.clone()
95                    } else {
96                        format!("{}::{}", rust_module, name)
97                    };
98                    type_map.insert(fqn.clone(), rust_path);
99                    package_of.insert(fqn.clone(), package.to_string());
100
101                    // Register nested messages using module-qualified paths.
102                    let snake = to_snake_case(name);
103                    let parent_mod = if rust_module.is_empty() {
104                        snake
105                    } else {
106                        format!("{}::{}", rust_module, snake)
107                    };
108                    register_nested_types(
109                        &mut type_map,
110                        &mut package_of,
111                        package,
112                        &fqn,
113                        &parent_mod,
114                        msg,
115                    );
116                    register_nested_enum_closedness(
117                        &mut enum_closedness,
118                        &fqn,
119                        &file_features,
120                        msg,
121                    );
122                }
123            }
124
125            // Register top-level enums
126            for enum_type in &file.enum_type {
127                if let Some(name) = &enum_type.name {
128                    let fqn = format!("{}{}", proto_prefix, name);
129                    let rust_path = if rust_module.is_empty() {
130                        name.clone()
131                    } else {
132                        format!("{}::{}", rust_module, name)
133                    };
134                    type_map.insert(fqn.clone(), rust_path);
135                    package_of.insert(fqn.clone(), package.to_string());
136                    register_enum_closedness(&mut enum_closedness, &fqn, &file_features, enum_type);
137                }
138            }
139        }
140
141        Self {
142            files,
143            config,
144            type_map,
145            package_of,
146            enum_closedness,
147            comment_map,
148        }
149    }
150
151    /// Build a context matching what [`generate()`](crate::generate) uses
152    /// internally.
153    ///
154    /// Computes effective extern paths (user-provided + auto-injected WKT
155    /// mapping to `buffa-types`) and builds the type map from them.
156    ///
157    /// Convenience for downstream generators (e.g. `connectrpc-codegen`)
158    /// that emit code alongside buffa's message types and need identical
159    /// type-path resolution. Using this instead of [`new()`](Self::new) +
160    /// manual extern-path computation ensures zero drift with buffa's own
161    /// generation.
162    pub fn for_generate(
163        files: &'a [FileDescriptorProto],
164        files_to_generate: &[String],
165        config: &'a CodeGenConfig,
166    ) -> Self {
167        let paths = crate::effective_extern_paths(files, files_to_generate, config);
168        Self::new(files, config, &paths)
169    }
170
171    /// Look up the Rust type path for a fully-qualified protobuf type name.
172    pub fn rust_type(&self, proto_fqn: &str) -> Option<&str> {
173        self.type_map.get(proto_fqn).map(|s| s.as_str())
174    }
175
176    /// Look up the source comment for a protobuf element by FQN.
177    ///
178    /// `fqn` uses the same dotted form as `proto_fqn` throughout codegen
179    /// (no leading dot). For sub-elements, append the element name:
180    /// - Message: `"pkg.Message"`
181    /// - Field: `"pkg.Message.field_name"`
182    /// - Enum value: `"pkg.Enum.VALUE_NAME"`
183    /// - Oneof: `"pkg.Message.oneof_name"`
184    pub fn comment(&self, fqn: &str) -> Option<&str> {
185        self.comment_map.get(fqn).map(|s| s.as_str())
186    }
187
188    /// Look up whether an enum (by fully-qualified proto name) is closed.
189    ///
190    /// Returns `None` if the enum is not in this compilation set (e.g., an
191    /// extern_path type), in which case callers should fall back to the
192    /// referencing field's feature chain (correct for proto2/proto3 where
193    /// `enum_type` is file-level anyway).
194    pub fn is_enum_closed(&self, proto_fqn: &str) -> Option<bool> {
195        self.enum_closedness.get(proto_fqn).copied()
196    }
197
198    /// Look up the Rust type path relative to the current code generation
199    /// scope.
200    ///
201    /// `current_package` is the proto package (e.g., `"google.protobuf"`).
202    /// `nesting` is the number of message module levels the generated code
203    /// sits inside (0 for struct fields and impls at the package level,
204    /// 1 for oneof enums inside a message module, etc.).
205    ///
206    /// - **Same package**: strips the package prefix and prepends `super::`
207    ///   for each nesting level.
208    /// - **Cross package (local)**: navigates via `super::` to the common
209    ///   ancestor, then descends into the target package. This works
210    ///   regardless of where the module tree is placed in the user's crate.
211    /// - **Cross package (extern)**: returns the absolute extern path as-is.
212    pub fn rust_type_relative(
213        &self,
214        proto_fqn: &str,
215        current_package: &str,
216        nesting: usize,
217    ) -> Option<String> {
218        let full_path = self.type_map.get(proto_fqn)?;
219
220        // Extern types use absolute paths (starting with `::` or `crate::`)
221        // and need no relative resolution — they work from any module position.
222        if full_path.starts_with("::") || full_path.starts_with("crate::") {
223            return Some(full_path.clone());
224        }
225
226        let target_package = self
227            .package_of
228            .get(proto_fqn)
229            .map(|s| s.as_str())
230            .unwrap_or("");
231
232        // Extract the type's path within its package (everything after the
233        // package module prefix).
234        let target_rust_module = target_package.replace('.', "::");
235        let type_suffix = if target_rust_module.is_empty() {
236            full_path.as_str()
237        } else {
238            full_path
239                .strip_prefix(&format!("{}::", target_rust_module))
240                .unwrap_or(full_path)
241        };
242
243        if current_package == target_package {
244            // Same package — just the type suffix, with super:: for nesting.
245            if nesting == 0 {
246                return Some(type_suffix.to_string());
247            }
248            let supers = (0..nesting).map(|_| "super").collect::<Vec<_>>().join("::");
249            return Some(format!("{}::{}", supers, type_suffix));
250        }
251
252        // Cross-package local type: compute a super::-based relative path.
253        let current_parts: Vec<&str> = if current_package.is_empty() {
254            vec![]
255        } else {
256            current_package.split('.').collect()
257        };
258        let target_parts: Vec<&str> = if target_package.is_empty() {
259            vec![]
260        } else {
261            target_package.split('.').collect()
262        };
263
264        // Find the length of the common package prefix.
265        let common_len = current_parts
266            .iter()
267            .zip(&target_parts)
268            .take_while(|(a, b)| a == b)
269            .count();
270
271        // Navigate up: one super:: per remaining current package segment,
272        // plus one per nesting level (message module depth).
273        let up_count = (current_parts.len() - common_len) + nesting;
274
275        // Navigate down: target package segments beyond the common prefix.
276        let down_parts = &target_parts[common_len..];
277
278        let mut segments: Vec<&str> = vec!["super"; up_count];
279        segments.extend_from_slice(down_parts);
280
281        // Append the type's within-package path.
282        let mut result = segments.join("::");
283        if !result.is_empty() {
284            result.push_str("::");
285        }
286        result.push_str(type_suffix);
287
288        Some(result)
289    }
290
291    /// Check whether a bytes field at the given proto path should use
292    /// `bytes::Bytes` instead of `Vec<u8>`.
293    ///
294    /// `field_fqn` is the fully-qualified proto field path, e.g.,
295    /// `".my.pkg.MyMessage.data"`. Matches against `config.bytes_fields`
296    /// entries, which are prefix-matched (so `"."` matches all fields).
297    pub fn use_bytes_type(&self, field_fqn: &str) -> bool {
298        self.config
299            .bytes_fields
300            .iter()
301            .any(|prefix| field_fqn.starts_with(prefix.as_str()))
302    }
303}
304
305/// Check if a proto package matches any extern_path prefix.
306///
307/// Returns the Rust module path root if matched, including any remaining
308/// package segments converted to `snake_case` modules. For example,
309/// extern_path `(".my", "::my_crate")` with package `"my.sub.pkg"` returns
310/// `"::my_crate::sub::pkg"`.
311fn resolve_extern_prefix(package: &str, extern_paths: &[(String, String)]) -> Option<String> {
312    let dotted = format!(".{}", package);
313
314    // Try longest prefix first so that more specific mappings take priority
315    // over broader ones (e.g., ".my.common" before ".my").
316    let mut best: Option<(&str, &str, usize)> = None;
317
318    for (proto_prefix, rust_prefix) in extern_paths {
319        if dotted == *proto_prefix {
320            // Exact match is always the best.
321            return Some(rust_prefix.clone());
322        }
323        if let Some(rest) = dotted.strip_prefix(proto_prefix.as_str()) {
324            // `"."` is the catch-all root; stripping it leaves no leading dot.
325            if proto_prefix == "." || rest.starts_with('.') {
326                let prefix_len = proto_prefix.len();
327                if best.is_none_or(|(_, _, best_len)| prefix_len > best_len) {
328                    best = Some((proto_prefix, rust_prefix, prefix_len));
329                }
330            }
331        }
332    }
333
334    let (proto_prefix, rust_prefix, _) = best?;
335    let rest = dotted.strip_prefix(proto_prefix)?;
336    let rest = rest.strip_prefix('.').unwrap_or(rest);
337    let suffix = rest
338        .split('.')
339        .map(to_snake_case)
340        .collect::<Vec<_>>()
341        .join("::");
342    Some(format!("{}::{}", rust_prefix, suffix))
343}
344
345/// Recursively register nested messages and enums with module-qualified paths.
346///
347/// Each nested message `Parent.Child` maps to `parent_mod::Child` in Rust,
348/// where `parent_mod` is the snake_case module path of the enclosing message.
349fn register_nested_types(
350    type_map: &mut HashMap<String, String>,
351    package_of: &mut HashMap<String, String>,
352    package: &str,
353    parent_fqn: &str,
354    parent_mod: &str,
355    msg: &crate::generated::descriptor::DescriptorProto,
356) {
357    for nested in &msg.nested_type {
358        if let Some(name) = &nested.name {
359            let fqn = format!("{}.{}", parent_fqn, name);
360            let rust_path = format!("{}::{}", parent_mod, name);
361            type_map.insert(fqn.clone(), rust_path);
362            package_of.insert(fqn.clone(), package.to_string());
363
364            // Recurse: nested-of-nested goes in a deeper module.
365            let child_mod = format!("{}::{}", parent_mod, to_snake_case(name));
366            register_nested_types(type_map, package_of, package, &fqn, &child_mod, nested);
367        }
368    }
369
370    for enum_type in &msg.enum_type {
371        if let Some(name) = &enum_type.name {
372            let fqn = format!("{}.{}", parent_fqn, name);
373            let rust_path = format!("{}::{}", parent_mod, name);
374            type_map.insert(fqn.clone(), rust_path);
375            package_of.insert(fqn, package.to_string());
376        }
377    }
378}
379
380/// Resolve and record whether an enum is closed, given its parent's features.
381fn register_enum_closedness(
382    map: &mut HashMap<String, bool>,
383    fqn: &str,
384    parent_features: &ResolvedFeatures,
385    enum_desc: &EnumDescriptorProto,
386) {
387    let resolved = features::resolve_child(parent_features, features::enum_features(enum_desc));
388    let closed = resolved.enum_type == features::EnumType::Closed;
389    map.insert(fqn.to_string(), closed);
390}
391
392/// Walk nested messages and register all enum closedness, resolving features
393/// through the message hierarchy (file → msg → nested_msg → enum).
394fn register_nested_enum_closedness(
395    map: &mut HashMap<String, bool>,
396    parent_fqn: &str,
397    parent_features: &ResolvedFeatures,
398    msg: &DescriptorProto,
399) {
400    let msg_features = features::resolve_child(parent_features, features::message_features(msg));
401    for enum_type in &msg.enum_type {
402        if let Some(name) = &enum_type.name {
403            let fqn = format!("{}.{}", parent_fqn, name);
404            register_enum_closedness(map, &fqn, &msg_features, enum_type);
405        }
406    }
407    for nested in &msg.nested_type {
408        if let Some(name) = &nested.name {
409            let fqn = format!("{}.{}", parent_fqn, name);
410            register_nested_enum_closedness(map, &fqn, &msg_features, nested);
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::generated::descriptor::{DescriptorProto, EnumDescriptorProto, FileDescriptorProto};
419
420    fn make_file(
421        name: &str,
422        package: &str,
423        messages: Vec<DescriptorProto>,
424        enums: Vec<EnumDescriptorProto>,
425    ) -> FileDescriptorProto {
426        FileDescriptorProto {
427            name: Some(name.to_string()),
428            package: if package.is_empty() {
429                None
430            } else {
431                Some(package.to_string())
432            },
433            message_type: messages,
434            enum_type: enums,
435            ..Default::default()
436        }
437    }
438
439    fn msg(name: &str) -> DescriptorProto {
440        DescriptorProto {
441            name: Some(name.to_string()),
442            ..Default::default()
443        }
444    }
445
446    fn msg_with_nested(name: &str, nested: Vec<DescriptorProto>) -> DescriptorProto {
447        DescriptorProto {
448            name: Some(name.to_string()),
449            nested_type: nested,
450            ..Default::default()
451        }
452    }
453
454    fn msg_with_nested_and_enums(
455        name: &str,
456        nested: Vec<DescriptorProto>,
457        enums: Vec<EnumDescriptorProto>,
458    ) -> DescriptorProto {
459        DescriptorProto {
460            name: Some(name.to_string()),
461            nested_type: nested,
462            enum_type: enums,
463            ..Default::default()
464        }
465    }
466
467    fn enum_desc(name: &str) -> EnumDescriptorProto {
468        EnumDescriptorProto {
469            name: Some(name.to_string()),
470            ..Default::default()
471        }
472    }
473
474    fn enum_with_closed_feature(name: &str) -> EnumDescriptorProto {
475        use crate::generated::descriptor::{feature_set, EnumOptions, FeatureSet};
476        EnumDescriptorProto {
477            name: Some(name.to_string()),
478            options: buffa::MessageField::some(EnumOptions {
479                features: buffa::MessageField::some(FeatureSet {
480                    enum_type: Some(feature_set::EnumType::CLOSED),
481                    ..Default::default()
482                }),
483                ..Default::default()
484            }),
485            ..Default::default()
486        }
487    }
488
489    fn editions_file(
490        name: &str,
491        package: &str,
492        messages: Vec<DescriptorProto>,
493        enums: Vec<EnumDescriptorProto>,
494    ) -> FileDescriptorProto {
495        use crate::generated::descriptor::Edition;
496        FileDescriptorProto {
497            name: Some(name.to_string()),
498            package: Some(package.to_string()),
499            syntax: Some("editions".to_string()),
500            edition: Some(Edition::EDITION_2023),
501            message_type: messages,
502            enum_type: enums,
503            ..Default::default()
504        }
505    }
506
507    // ── Type registration tests ──────────────────────────────────────────
508
509    #[test]
510    fn test_message_with_package() {
511        let files = [make_file(
512            "test.proto",
513            "my.package",
514            vec![msg("Foo")],
515            vec![],
516        )];
517        let config = CodeGenConfig::default();
518        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
519        assert_eq!(ctx.rust_type(".my.package.Foo"), Some("my::package::Foo"));
520    }
521
522    #[test]
523    fn test_message_no_package() {
524        let files = [make_file("test.proto", "", vec![msg("Bar")], vec![])];
525        let config = CodeGenConfig::default();
526        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
527        assert_eq!(ctx.rust_type(".Bar"), Some("Bar"));
528    }
529
530    #[test]
531    fn test_nested_message_uses_module_path() {
532        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
533        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
534        let config = CodeGenConfig::default();
535        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
536        assert_eq!(ctx.rust_type(".pkg.Outer"), Some("pkg::Outer"));
537        // Nested types use module-qualified paths.
538        assert_eq!(ctx.rust_type(".pkg.Outer.Inner"), Some("pkg::outer::Inner"));
539    }
540
541    #[test]
542    fn test_nested_message_no_package() {
543        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
544        let files = [make_file("test.proto", "", vec![outer], vec![])];
545        let config = CodeGenConfig::default();
546        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
547        assert_eq!(ctx.rust_type(".Outer"), Some("Outer"));
548        assert_eq!(ctx.rust_type(".Outer.Inner"), Some("outer::Inner"));
549    }
550
551    #[test]
552    fn test_deeply_nested_message() {
553        let deep = msg_with_nested("A", vec![msg_with_nested("B", vec![msg("C")])]);
554        let files = [make_file("test.proto", "pkg", vec![deep], vec![])];
555        let config = CodeGenConfig::default();
556        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
557        assert_eq!(ctx.rust_type(".pkg.A"), Some("pkg::A"));
558        assert_eq!(ctx.rust_type(".pkg.A.B"), Some("pkg::a::B"));
559        assert_eq!(ctx.rust_type(".pkg.A.B.C"), Some("pkg::a::b::C"));
560    }
561
562    #[test]
563    fn test_nested_enum_uses_module_path() {
564        let outer = msg_with_nested_and_enums("Outer", vec![], vec![enum_desc("Status")]);
565        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
566        let config = CodeGenConfig::default();
567        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
568        assert_eq!(
569            ctx.rust_type(".pkg.Outer.Status"),
570            Some("pkg::outer::Status")
571        );
572    }
573
574    #[test]
575    fn test_top_level_enum() {
576        let files = [make_file(
577            "test.proto",
578            "pkg",
579            vec![],
580            vec![enum_desc("Status")],
581        )];
582        let config = CodeGenConfig::default();
583        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
584        assert_eq!(ctx.rust_type(".pkg.Status"), Some("pkg::Status"));
585    }
586
587    #[test]
588    fn test_same_named_nested_types_in_different_parents_are_distinct() {
589        let outer1 = msg_with_nested("Outer1", vec![msg("Inner")]);
590        let outer2 = msg_with_nested("Outer2", vec![msg("Inner")]);
591        let files = [make_file("a.proto", "pkg", vec![outer1, outer2], vec![])];
592        let config = CodeGenConfig::default();
593        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
594        // Different parent modules make them distinct.
595        assert_eq!(
596            ctx.rust_type(".pkg.Outer1.Inner"),
597            Some("pkg::outer1::Inner")
598        );
599        assert_eq!(
600            ctx.rust_type(".pkg.Outer2.Inner"),
601            Some("pkg::outer2::Inner")
602        );
603        assert_ne!(
604            ctx.rust_type(".pkg.Outer1.Inner"),
605            ctx.rust_type(".pkg.Outer2.Inner")
606        );
607    }
608
609    #[test]
610    fn test_multiple_files() {
611        let files = [
612            make_file("a.proto", "ns.a", vec![msg("MsgA")], vec![]),
613            make_file("b.proto", "ns.b", vec![msg("MsgB")], vec![]),
614        ];
615        let config = CodeGenConfig::default();
616        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
617        assert_eq!(ctx.rust_type(".ns.a.MsgA"), Some("ns::a::MsgA"));
618        assert_eq!(ctx.rust_type(".ns.b.MsgB"), Some("ns::b::MsgB"));
619    }
620
621    #[test]
622    fn test_keyword_package_segment_in_type_map() {
623        // Proto package `google.type` — the type map stores plain string paths.
624        // Keyword escaping happens at the token level, not in the type map.
625        let files = [make_file(
626            "latlng.proto",
627            "google.type",
628            vec![msg("LatLng")],
629            vec![],
630        )];
631        let config = CodeGenConfig::default();
632        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
633        assert_eq!(
634            ctx.rust_type(".google.type.LatLng"),
635            Some("google::type::LatLng")
636        );
637    }
638
639    #[test]
640    fn test_keyword_package_relative_same_package() {
641        let files = [make_file(
642            "latlng.proto",
643            "google.type",
644            vec![msg("LatLng"), msg("Expr")],
645            vec![],
646        )];
647        let config = CodeGenConfig::default();
648        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
649        // Same-package reference: just the type name (no module prefix).
650        assert_eq!(
651            ctx.rust_type_relative(".google.type.LatLng", "google.type", 0),
652            Some("LatLng".into())
653        );
654    }
655
656    #[test]
657    fn test_keyword_package_cross_package() {
658        let files = [
659            make_file("latlng.proto", "google.type", vec![msg("LatLng")], vec![]),
660            make_file("svc.proto", "google.cloud", vec![msg("Service")], vec![]),
661        ];
662        let config = CodeGenConfig::default();
663        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
664        // Cross-package: relative path via super:: (keyword escaping at token level).
665        // From google.cloud, go up one (past "cloud"), then into "type".
666        assert_eq!(
667            ctx.rust_type_relative(".google.type.LatLng", "google.cloud", 0),
668            Some("super::type::LatLng".into())
669        );
670    }
671
672    #[test]
673    fn test_keyword_nested_message_module() {
674        // Message named "Type" → module "type" in type map.
675        let outer = msg_with_nested("Type", vec![msg("Inner")]);
676        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
677        let config = CodeGenConfig::default();
678        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
679        assert_eq!(ctx.rust_type(".pkg.Type"), Some("pkg::Type"));
680        assert_eq!(ctx.rust_type(".pkg.Type.Inner"), Some("pkg::type::Inner"));
681    }
682
683    #[test]
684    fn test_unknown_type_returns_none() {
685        let files = [make_file("test.proto", "pkg", vec![msg("Foo")], vec![])];
686        let config = CodeGenConfig::default();
687        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
688        assert_eq!(ctx.rust_type(".pkg.Unknown"), None);
689    }
690
691    // ── Relative type resolution tests ───────────────────────────────────
692
693    #[test]
694    fn test_relative_same_package_top_level() {
695        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
696        let config = CodeGenConfig::default();
697        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
698        // From top-level in same package: just the type name.
699        assert_eq!(
700            ctx.rust_type_relative(".pkg.Foo", "pkg", 0),
701            Some("Foo".into())
702        );
703    }
704
705    #[test]
706    fn test_relative_cross_package() {
707        let files = [
708            make_file("a.proto", "pkg_a", vec![msg("Foo")], vec![]),
709            make_file("b.proto", "pkg_b", vec![msg("Bar")], vec![]),
710        ];
711        let config = CodeGenConfig::default();
712        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
713        // Cross-package: relative via super:: (up one from pkg_b, into pkg_a).
714        assert_eq!(
715            ctx.rust_type_relative(".pkg_a.Foo", "pkg_b", 0),
716            Some("super::pkg_a::Foo".into())
717        );
718    }
719
720    #[test]
721    fn test_relative_no_package() {
722        let files = [make_file("a.proto", "", vec![msg("Foo")], vec![])];
723        let config = CodeGenConfig::default();
724        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
725        assert_eq!(ctx.rust_type_relative(".Foo", "", 0), Some("Foo".into()));
726    }
727
728    #[test]
729    fn test_relative_unknown_returns_none() {
730        let files = [make_file("a.proto", "pkg", vec![msg("Foo")], vec![])];
731        let config = CodeGenConfig::default();
732        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
733        assert_eq!(ctx.rust_type_relative(".pkg.Unknown", "pkg", 0), None);
734    }
735
736    #[test]
737    fn test_relative_dotted_package() {
738        let files = [make_file("a.proto", "my.pkg", vec![msg("Foo")], vec![])];
739        let config = CodeGenConfig::default();
740        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
741        assert_eq!(
742            ctx.rust_type_relative(".my.pkg.Foo", "my.pkg", 0),
743            Some("Foo".into())
744        );
745    }
746
747    #[test]
748    fn test_relative_cross_dotted_packages() {
749        let files = [
750            make_file(
751                "timestamp.proto",
752                "google.protobuf",
753                vec![msg("Timestamp")],
754                vec![],
755            ),
756            make_file(
757                "test.proto",
758                "protobuf_test_messages.proto3",
759                vec![msg("TestAllTypesProto3")],
760                vec![],
761            ),
762        ];
763        let config = CodeGenConfig::default();
764        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
765
766        // Cross-package: relative via super:: (no common prefix, up 2 levels).
767        assert_eq!(
768            ctx.rust_type_relative(
769                ".google.protobuf.Timestamp",
770                "protobuf_test_messages.proto3",
771                0,
772            ),
773            Some("super::super::google::protobuf::Timestamp".into())
774        );
775    }
776
777    #[test]
778    fn test_relative_nested_type_from_same_package() {
779        // Referencing Outer.Inner from the same package.
780        let outer = msg_with_nested("Outer", vec![msg("Inner")]);
781        let files = [make_file("test.proto", "pkg", vec![outer], vec![])];
782        let config = CodeGenConfig::default();
783        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
784
785        // Same package: strips the package prefix, keeps module path.
786        assert_eq!(
787            ctx.rust_type_relative(".pkg.Outer.Inner", "pkg", 0),
788            Some("outer::Inner".into())
789        );
790    }
791
792    #[test]
793    fn test_relative_shared_prefix_not_confused() {
794        let files = [
795            make_file("ab.proto", "a.b", vec![msg("Msg1")], vec![]),
796            make_file("abc.proto", "a.bc", vec![msg("Msg2")], vec![]),
797        ];
798        let config = CodeGenConfig::default();
799        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
800
801        // `a.b.Msg1` from `a.bc` context: common prefix "a", up 1, into "b".
802        assert_eq!(
803            ctx.rust_type_relative(".a.b.Msg1", "a.bc", 0),
804            Some("super::b::Msg1".into())
805        );
806        // `a.bc.Msg2` from `a.b` context: common prefix "a", up 1, into "bc".
807        assert_eq!(
808            ctx.rust_type_relative(".a.bc.Msg2", "a.b", 0),
809            Some("super::bc::Msg2".into())
810        );
811    }
812
813    // ── Extern path tests ─────────────────────────────────────────────
814
815    #[test]
816    fn test_resolve_extern_prefix_exact_match() {
817        let result = resolve_extern_prefix(
818            "my.common",
819            &[(".my.common".into(), "::common_protos".into())],
820        );
821        assert_eq!(result, Some("::common_protos".into()));
822    }
823
824    #[test]
825    fn test_resolve_extern_prefix_sub_package() {
826        let result = resolve_extern_prefix(
827            "my.common.sub",
828            &[(".my.common".into(), "::common_protos".into())],
829        );
830        assert_eq!(result, Some("::common_protos::sub".into()));
831    }
832
833    #[test]
834    fn test_resolve_extern_prefix_no_match() {
835        let result = resolve_extern_prefix(
836            "other.pkg",
837            &[(".my.common".into(), "::common_protos".into())],
838        );
839        assert_eq!(result, None);
840    }
841
842    #[test]
843    fn test_resolve_extern_prefix_partial_name_no_match() {
844        // ".my.common" should not match ".my.commonext"
845        let result = resolve_extern_prefix(
846            "my.commonext",
847            &[(".my.common".into(), "::common_protos".into())],
848        );
849        assert_eq!(result, None);
850    }
851
852    #[test]
853    fn test_resolve_extern_prefix_longest_match_wins() {
854        // When multiple prefixes match, the longest one should win.
855        let result = resolve_extern_prefix(
856            "my.common.sub",
857            &[
858                (".my".into(), "::crate_a".into()),
859                (".my.common".into(), "::crate_b".into()),
860            ],
861        );
862        assert_eq!(result, Some("::crate_b::sub".into()));
863    }
864
865    #[test]
866    fn test_resolve_extern_prefix_catchall() {
867        let result = resolve_extern_prefix("greet.v1", &[(".".into(), "crate::proto".into())]);
868        assert_eq!(result, Some("crate::proto::greet::v1".into()));
869    }
870
871    #[test]
872    fn test_resolve_extern_prefix_catchall_empty_pkg() {
873        // Empty package with `.` catch-all hits the exact-match branch
874        // (dotted == "." == proto_prefix) and returns the root as-is.
875        let result = resolve_extern_prefix("", &[(".".into(), "crate::proto".into())]);
876        assert_eq!(result, Some("crate::proto".into()));
877    }
878
879    #[test]
880    fn test_resolve_extern_prefix_catchall_longest_wins() {
881        // `.` catch-all is the shortest possible prefix; any more-specific
882        // mapping (including the auto-injected WKT mapping) takes priority.
883        let result = resolve_extern_prefix(
884            "google.protobuf",
885            &[
886                (".".into(), "crate::proto".into()),
887                (
888                    ".google.protobuf".into(),
889                    "::buffa_types::google::protobuf".into(),
890                ),
891            ],
892        );
893        assert_eq!(result, Some("::buffa_types::google::protobuf".into()));
894    }
895
896    #[test]
897    fn test_resolve_extern_prefix_catchall_keyword_package() {
898        // Keyword segments stay unescaped at the string level; escaping to
899        // `r#type` happens later in `idents::rust_path_to_tokens`.
900        let result = resolve_extern_prefix("google.type", &[(".".into(), "crate::proto".into())]);
901        assert_eq!(result, Some("crate::proto::google::type".into()));
902    }
903
904    #[test]
905    fn test_extern_path_top_level_message() {
906        let files = [make_file(
907            "common.proto",
908            "my.common",
909            vec![msg("SharedMsg")],
910            vec![],
911        )];
912        let config = CodeGenConfig {
913            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
914            ..Default::default()
915        };
916        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
917        assert_eq!(
918            ctx.rust_type(".my.common.SharedMsg"),
919            Some("::common_protos::SharedMsg")
920        );
921    }
922
923    #[test]
924    fn test_extern_path_nested_message() {
925        let files = [make_file(
926            "common.proto",
927            "my.common",
928            vec![msg_with_nested("Outer", vec![msg("Inner")])],
929            vec![],
930        )];
931        let config = CodeGenConfig {
932            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
933            ..Default::default()
934        };
935        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
936        assert_eq!(
937            ctx.rust_type(".my.common.Outer"),
938            Some("::common_protos::Outer")
939        );
940        assert_eq!(
941            ctx.rust_type(".my.common.Outer.Inner"),
942            Some("::common_protos::outer::Inner")
943        );
944    }
945
946    #[test]
947    fn test_extern_path_enum() {
948        let files = [make_file(
949            "common.proto",
950            "my.common",
951            vec![],
952            vec![enum_desc("Status")],
953        )];
954        let config = CodeGenConfig {
955            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
956            ..Default::default()
957        };
958        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
959        assert_eq!(
960            ctx.rust_type(".my.common.Status"),
961            Some("::common_protos::Status")
962        );
963    }
964
965    #[test]
966    fn test_extern_path_does_not_affect_other_packages() {
967        let files = [
968            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
969            make_file(
970                "service.proto",
971                "my.service",
972                vec![msg("MyService")],
973                vec![],
974            ),
975        ];
976        let config = CodeGenConfig {
977            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
978            ..Default::default()
979        };
980        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
981        // Extern type uses absolute path.
982        assert_eq!(
983            ctx.rust_type(".my.common.SharedMsg"),
984            Some("::common_protos::SharedMsg")
985        );
986        // Non-extern type uses normal package-derived path.
987        assert_eq!(
988            ctx.rust_type(".my.service.MyService"),
989            Some("my::service::MyService")
990        );
991    }
992
993    #[test]
994    fn test_extern_path_relative_returns_absolute() {
995        // When an extern type is referenced from another package,
996        // rust_type_relative should return the full absolute path.
997        let files = [
998            make_file("common.proto", "my.common", vec![msg("SharedMsg")], vec![]),
999            make_file(
1000                "service.proto",
1001                "my.service",
1002                vec![msg("MyService")],
1003                vec![],
1004            ),
1005        ];
1006        let config = CodeGenConfig {
1007            extern_paths: vec![(".my.common".into(), "::common_protos".into())],
1008            ..Default::default()
1009        };
1010        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1011        // Cross-package reference to extern type: absolute path.
1012        assert_eq!(
1013            ctx.rust_type_relative(".my.common.SharedMsg", "my.service", 0),
1014            Some("::common_protos::SharedMsg".into())
1015        );
1016    }
1017
1018    // ── is_enum_closed tests ──────────────────────────────────────────────
1019
1020    #[test]
1021    fn test_is_enum_closed_proto3_default_open() {
1022        let files = [make_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1023        let config = CodeGenConfig::default();
1024        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1025        // proto3 default (make_file has no syntax = proto2/implicit)
1026        // actually make_file doesn't set syntax, so it's proto2 default...
1027        // proto2 default is CLOSED.
1028        assert_eq!(ctx.is_enum_closed(".p.E"), Some(true));
1029    }
1030
1031    #[test]
1032    fn test_is_enum_closed_editions_default_open() {
1033        let files = [editions_file("a.proto", "p", vec![], vec![enum_desc("E")])];
1034        let config = CodeGenConfig::default();
1035        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1036        // Edition 2023 default is OPEN.
1037        assert_eq!(ctx.is_enum_closed(".p.E"), Some(false));
1038    }
1039
1040    #[test]
1041    fn test_is_enum_closed_per_enum_override() {
1042        // This is THE bug: enum with `option features.enum_type = CLOSED`
1043        // in an otherwise-open editions file must be detected as closed.
1044        let files = [editions_file(
1045            "a.proto",
1046            "p",
1047            vec![],
1048            vec![enum_desc("Open"), enum_with_closed_feature("Closed")],
1049        )];
1050        let config = CodeGenConfig::default();
1051        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1052        assert_eq!(ctx.is_enum_closed(".p.Open"), Some(false));
1053        assert_eq!(ctx.is_enum_closed(".p.Closed"), Some(true));
1054    }
1055
1056    #[test]
1057    fn test_is_enum_closed_nested_per_enum_override() {
1058        // Feature resolution through file → message → enum.
1059        let files = [editions_file(
1060            "a.proto",
1061            "p",
1062            vec![msg_with_nested_and_enums(
1063                "M",
1064                vec![],
1065                vec![enum_with_closed_feature("Inner")],
1066            )],
1067            vec![],
1068        )];
1069        let config = CodeGenConfig::default();
1070        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1071        assert_eq!(ctx.is_enum_closed(".p.M.Inner"), Some(true));
1072    }
1073
1074    #[test]
1075    fn test_is_enum_closed_unknown_enum_returns_none() {
1076        let files = [editions_file("a.proto", "p", vec![], vec![])];
1077        let config = CodeGenConfig::default();
1078        let ctx = CodeGenContext::new(&files, &config, &config.extern_paths);
1079        // extern_path or missing enum → None (caller falls back).
1080        assert_eq!(ctx.is_enum_closed(".other.Unknown"), None);
1081    }
1082
1083    #[test]
1084    fn test_for_generate_auto_injects_wkt_mapping() {
1085        // for_generate() must produce the same type_map as generate() uses
1086        // internally — including the auto-injected WKT extern_path.
1087        let ts_msg = DescriptorProto {
1088            name: Some("Timestamp".into()),
1089            ..Default::default()
1090        };
1091        let files = [FileDescriptorProto {
1092            name: Some("google/protobuf/timestamp.proto".into()),
1093            package: Some("google.protobuf".into()),
1094            syntax: Some("proto3".into()),
1095            message_type: vec![ts_msg],
1096            ..Default::default()
1097        }];
1098        let config = CodeGenConfig::default();
1099        // Not generating the WKT file itself → auto-mapping should kick in.
1100        let ctx = CodeGenContext::for_generate(&files, &["other.proto".into()], &config);
1101        assert_eq!(
1102            ctx.rust_type(".google.protobuf.Timestamp"),
1103            Some("::buffa_types::google::protobuf::Timestamp"),
1104            "WKT auto-mapping must be applied via for_generate"
1105        );
1106    }
1107
1108    #[test]
1109    fn test_for_generate_suppresses_wkt_when_generating_wkt() {
1110        // When files_to_generate includes a google.protobuf file (building
1111        // buffa-types itself), the WKT auto-mapping must NOT be applied.
1112        let ts_msg = DescriptorProto {
1113            name: Some("Timestamp".into()),
1114            ..Default::default()
1115        };
1116        let files = [FileDescriptorProto {
1117            name: Some("google/protobuf/timestamp.proto".into()),
1118            package: Some("google.protobuf".into()),
1119            syntax: Some("proto3".into()),
1120            message_type: vec![ts_msg],
1121            ..Default::default()
1122        }];
1123        let config = CodeGenConfig::default();
1124        let ctx = CodeGenContext::for_generate(
1125            &files,
1126            &["google/protobuf/timestamp.proto".into()],
1127            &config,
1128        );
1129        // No extern mapping → local-package path.
1130        assert_eq!(
1131            ctx.rust_type(".google.protobuf.Timestamp"),
1132            Some("google::protobuf::Timestamp")
1133        );
1134    }
1135}