Skip to main content

connectrpc_codegen/
codegen.rs

1//! Code generation logic for ConnectRPC Rust bindings.
2//!
3//! This module generates:
4//! - Buffa message types (via buffa-codegen)
5//! - ConnectRPC service traits and clients
6//!
7//! Code generation uses the `quote` crate for producing Rust code from
8//! TokenStreams, which provides better syntax highlighting, type safety,
9//! and maintainability compared to string-based generation.
10
11use std::collections::HashMap;
12
13use anyhow::Result;
14use heck::ToSnakeCase;
15use heck::ToUpperCamelCase;
16use proc_macro2::{Ident, TokenStream};
17use quote::format_ident;
18use quote::quote;
19
20use buffa_codegen::generated::descriptor::FileDescriptorProto;
21use buffa_codegen::generated::descriptor::MethodDescriptorProto;
22use buffa_codegen::generated::descriptor::ServiceDescriptorProto;
23use buffa_codegen::generated::descriptor::SourceCodeInfo;
24use buffa_codegen::generated::descriptor::method_options::IdempotencyLevel;
25use buffa_codegen::idents::make_field_ident;
26use buffa_codegen::idents::rust_path_to_tokens;
27
28pub use buffa_codegen::generated::descriptor;
29pub use buffa_codegen::{CodeGenConfig, GeneratedFile, GeneratedFileKind};
30
31use crate::plugin::CodeGeneratorRequest;
32use crate::plugin::CodeGeneratorResponse;
33use crate::plugin::CodeGeneratorResponseFile;
34
35/// Options for ConnectRPC code generation.
36///
37/// These control both the underlying buffa message generation and the
38/// ConnectRPC service binding generation.
39///
40/// Construct via `Options::default()` then set fields on `buffa` directly
41/// (the struct is `#[non_exhaustive]`, so struct-update syntax is
42/// unavailable from outside this crate).
43#[derive(Debug, Clone)]
44#[non_exhaustive]
45pub struct Options {
46    /// The underlying buffa-codegen configuration. Set any
47    /// [`CodeGenConfig`] field directly here; connectrpc passes it through
48    /// verbatim except for [`CodeGenConfig::generate_views`], which is
49    /// forced to `true` (service stubs require view types).
50    ///
51    /// [`Options::default()`] starts from buffa's defaults but enables
52    /// `generate_json` (the Connect protocol's JSON codec needs it; buffa's
53    /// own default is `false`).
54    ///
55    /// `buffa.extern_paths` is used by [`generate_services`] to bake
56    /// absolute paths into service stubs (set a `(".", "crate::proto")`
57    /// catch-all so every type resolves); it is ignored by
58    /// [`generate_files`] (the unified `super::`-relative path).
59    pub buffa: CodeGenConfig,
60}
61
62impl Default for Options {
63    fn default() -> Self {
64        let mut buffa = CodeGenConfig::default();
65        buffa.generate_json = true;
66        Self { buffa }
67    }
68}
69
70impl Options {
71    /// Clone the embedded buffa config and apply connectrpc's invariants
72    /// (`generate_views = true` — service stubs reference view types).
73    fn to_buffa_config(&self) -> CodeGenConfig {
74        let mut config = self.buffa.clone();
75        config.generate_views = true;
76        config
77    }
78}
79
80/// Emit one [`GeneratedFile`] per proto file in `file_to_generate` that
81/// declares at least one `service`. Files with no services produce no output.
82fn emit_service_files(
83    proto_file: &[FileDescriptorProto],
84    file_to_generate: &[String],
85    resolver: &TypeResolver<'_>,
86) -> Result<Vec<GeneratedFile>> {
87    let mut out = Vec::new();
88    // Dedup state shared across the whole batch, not per file:
89    // - output-type Encodable impls (else two files sharing an output
90    //   type collide with E0119);
91    // - OwnedFooView aliases keyed on (package, fqn) (else two files in
92    //   the same package collide with E0428);
93    // - colliding-alias detection (issue #75) needs full-batch visibility
94    //   because the stitcher mounts sibling files into one module.
95    let mut batch = BatchState {
96        colliding_aliases: collect_alias_collisions(proto_file, file_to_generate),
97        ..BatchState::default()
98    };
99    for file_name in file_to_generate {
100        let file_desc = proto_file
101            .iter()
102            .find(|f| f.name.as_deref() == Some(file_name.as_str()));
103
104        if let Some(file) = file_desc
105            && !file.service.is_empty()
106        {
107            let service_tokens = generate_connect_services(file, resolver, &mut batch)?;
108            let service_code = format_token_stream(&service_tokens)?;
109            // Companion files are connect-rust's contribution alongside
110            // buffa's per-proto outputs. The `.__connect.rs` suffix avoids
111            // colliding with any of buffa's own filenames in the unified
112            // path (`<stem>.rs`, `<stem>.__view.rs`, ...) per the
113            // `apply_companions` contract; in the split path the plugin
114            // writes to its own output directory so the suffix is just a
115            // visible marker of the file's origin.
116            out.push(GeneratedFile {
117                name: format!(
118                    "{}.__connect.rs",
119                    buffa_codegen::proto_path_to_stem(file_name)
120                ),
121                package: file.package.clone().unwrap_or_default(),
122                kind: GeneratedFileKind::Companion,
123                content: service_code,
124            });
125        }
126    }
127    Ok(out)
128}
129
130/// Generate ConnectRPC service bindings + buffa message types from proto
131/// descriptors.
132///
133/// Returns buffa's per-proto [`GeneratedFile`]s (Owned, View, Oneof,
134/// ViewOneof, Ext, plus one PackageMod stitcher per package), with one
135/// [`GeneratedFileKind::Companion`] file per service-declaring proto
136/// (`<stem>.__connect.rs`) wired into the matching package stitcher via
137/// [`buffa_codegen::apply_companions`]. Callers write every file to disk
138/// and wire only the [`GeneratedFileKind::PackageMod`] entries into their
139/// module tree (the stitchers `include!` the rest).
140///
141/// Under [`CodeGenConfig::file_per_package`] no `Companion` files are
142/// emitted: the service stubs are inlined directly into buffa's single
143/// `<dotted.pkg>.rs` `PackageMod` per package, mirroring how buffa
144/// inlines its own ancillary content under that mode.
145///
146/// This is the **unified** path: service stubs reference message types via
147/// `super::`-relative paths, so both must live in the same module tree.
148/// [`CodeGenConfig::extern_paths`] is ignored.
149///
150/// # Errors
151///
152/// Returns an error if buffa-codegen fails (e.g. unsupported proto
153/// feature) or if the generated service binding Rust does not parse
154/// under `syn` (indicates a bug in this crate).
155pub fn generate_files(
156    proto_file: &[FileDescriptorProto],
157    file_to_generate: &[String],
158    options: &Options,
159) -> Result<Vec<GeneratedFile>> {
160    let config = options.to_buffa_config();
161
162    let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
163        .map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
164
165    let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
166    let service_files = emit_service_files(proto_file, file_to_generate, &resolver)?;
167
168    if config.file_per_package {
169        // Under `file_per_package` buffa emits one `<dotted.pkg>.rs`
170        // (kind `PackageMod`) per package, inlining what the per-file
171        // stitcher would otherwise `include!`. Inline the service stubs
172        // into it directly so the output stays single-file-per-package —
173        // a sibling `<stem>.__connect.rs` would defeat the layout's
174        // purpose (BSR/`tonic`-style `lib.rs` synthesis from
175        // `<dotted.package>.rs` filenames).
176        inline_companions_into_package_mods(&mut files, service_files);
177    } else {
178        // Wire each `<stem>.__connect.rs` into the matching per-package
179        // stitcher and append the companion files to the output set in one
180        // pass. Every companion's package has a matching PackageMod here
181        // because buffa unconditionally emits one for every package
182        // containing a `file_to_generate` proto, so no companion is ever
183        // orphaned.
184        buffa_codegen::apply_companions(&mut files, service_files);
185
186        // The orphaning safety above is a cross-crate invariant on buffa's
187        // output shape; if a future buffa release stops emitting a
188        // PackageMod for an empty package, `apply_companions` would
189        // silently append the companion without any stitcher wiring it in.
190        // Surface that early in debug builds rather than letting the
191        // trait/client vanish at use-site.
192        debug_assert!(
193            files.iter().all(|f| {
194                f.kind != GeneratedFileKind::Companion
195                    || files.iter().any(|g| {
196                        g.kind == GeneratedFileKind::PackageMod
197                            && g.content.contains(&format!("include!(\"{}\")", f.name))
198                    })
199            }),
200            "a companion service file was not wired into any package stitcher"
201        );
202    }
203
204    Ok(files)
205}
206
207/// Append each companion's content directly to the matching `PackageMod`,
208/// dropping the companion entries instead of `apply_companions`-ing them
209/// as separate `include!`d siblings.
210///
211/// Used by [`generate_files`] under [`CodeGenConfig::file_per_package`],
212/// where the `PackageMod` is the *only* per-package output file and a
213/// sibling `<stem>.__connect.rs` would break the single-file convention
214/// that BSR/`tonic`-style `lib.rs` synthesis depends on.
215///
216/// Companions whose package has no `PackageMod` are dropped — that does
217/// not arise in [`generate_files`] (buffa unconditionally emits one per
218/// `file_to_generate` package). Note this differs from `apply_companions`,
219/// which appends-without-wiring (the dangling `.__connect.rs` lands on
220/// disk as a debugging breadcrumb): here the orphan vanishes entirely.
221/// Both paths yield a missing-symbol error at the consumer, but the
222/// `debug_assert!` in [`generate_files`]'s default branch covers the
223/// dangerous half (silent unwired siblings); this branch has no sibling
224/// to leave dangling, so a vanished trait is the only signature.
225fn inline_companions_into_package_mods(
226    // Slice not Vec: this path mutates PackageMod content in place and
227    // never appends — companions are consumed by the loop, not retained.
228    files: &mut [GeneratedFile],
229    companions: Vec<GeneratedFile>,
230) {
231    // Symmetric to the `debug_assert!` in `generate_files`'s default branch:
232    // this branch leaves nothing on disk for an orphan, so the assertion is
233    // the *only* signal if buffa's PackageMod-emission contract changes.
234    debug_assert!(
235        companions.iter().all(|c| files
236            .iter()
237            .any(|f| f.kind == GeneratedFileKind::PackageMod && f.package == c.package)),
238        "a companion service file's package has no PackageMod to inline into"
239    );
240    for comp in companions {
241        if let Some(pkg_mod) = files
242            .iter_mut()
243            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == comp.package)
244        {
245            pkg_mod.content.push('\n');
246            pkg_mod.content.push_str(&comp.content);
247        }
248    }
249}
250
251/// Generate **only** ConnectRPC service bindings from proto descriptors.
252///
253/// Returns one `<stem>.__connect.rs` `GeneratedFile` per proto file in
254/// `file_to_generate` that declares at least one `service`, plus one
255/// `<pkg>.mod.rs` stitcher per package. No message types.
256///
257/// Service files carry [`GeneratedFileKind::Companion`] for symmetry with
258/// [`generate_files`], even though this path never calls
259/// `apply_companions`: the split-path stitcher emitted here `include!`s
260/// them directly. Build integrations filtering on kind should treat
261/// `Companion` as "connect-rust service stub" in both modes.
262///
263/// Under [`CodeGenConfig::file_per_package`] the per-proto split is
264/// collapsed: the output is exactly one `<dotted.pkg>.rs` (kind
265/// [`GeneratedFileKind::PackageMod`]) per package with all service stubs
266/// inlined, and no `<pkg>.mod.rs` stitcher. This matches the file layout
267/// `protoc-gen-buffa` produces under the same option and the convention
268/// that BSR cargo SDK generation and `tonic`-style build integrations
269/// expect (one `<dotted.package>.rs` per package, module tree synthesised
270/// from filenames). Route this output to its own directory — it shares
271/// `protoc-gen-buffa`'s filename per package and would silently overwrite
272/// in a shared one.
273///
274/// This is the **split** path: service stubs reference message types via
275/// absolute Rust paths derived from [`CodeGenConfig::extern_paths`]. Callers must
276/// set at least a `.` catch-all entry (e.g. `(".", "crate::proto")`) so
277/// every type resolves; the auto-injected WKT mapping still takes priority
278/// via longest-prefix-match. The generated code compiles standalone as long
279/// as the extern paths point at a buffa-generated module tree.
280///
281/// # Errors
282///
283/// Errors if any method input/output type is not covered by an extern_path
284/// mapping, or is absent from `proto_file` (missing import).
285pub fn generate_services(
286    proto_file: &[FileDescriptorProto],
287    file_to_generate: &[String],
288    options: &Options,
289) -> Result<Vec<GeneratedFile>> {
290    use std::collections::BTreeMap;
291
292    let config = options.to_buffa_config();
293    let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
294    let mut files = emit_service_files(proto_file, file_to_generate, &resolver)?;
295
296    if config.file_per_package {
297        // Collapse the per-proto split into one `<dotted.pkg>.rs` per
298        // package (kind `PackageMod`) with all service stubs inlined.
299        // No stitcher — module tree wiring is the consumer's job (BSR
300        // `lib.rs` synthesis, hand-written `mod.rs`, ...).
301        let mut by_package: BTreeMap<String, String> = BTreeMap::new();
302        for f in files {
303            let entry = by_package.entry(f.package).or_insert_with(|| {
304                String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n")
305            });
306            entry.push('\n');
307            entry.push_str(&f.content);
308        }
309        return Ok(by_package
310            .into_iter()
311            .map(|(package, content)| GeneratedFile {
312                name: buffa_codegen::package_to_filename(&package),
313                package,
314                kind: GeneratedFileKind::PackageMod,
315                content,
316            })
317            .collect());
318    }
319
320    // Emit a per-package `<pkg>.mod.rs` stitcher for each package with at
321    // least one service-declaring proto, so `protoc-gen-buffa-packaging`
322    // can wire this output the same way it wires buffa's. The stitcher
323    // here is trivial — just `include!("<stem>.__connect.rs")` per file;
324    // there's no view/oneof ancillary tree for service stubs.
325    let mut by_package: BTreeMap<String, Vec<String>> = BTreeMap::new();
326    for f in &files {
327        by_package
328            .entry(f.package.clone())
329            .or_default()
330            .push(f.name.clone());
331    }
332    for (package, names) in by_package {
333        let mut content = String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n");
334        for n in &names {
335            // {:?} on the filename gives a quoted, escaped string literal.
336            content.push_str(&format!("include!({n:?});\n"));
337        }
338        files.push(GeneratedFile {
339            name: buffa_codegen::package_to_mod_filename(&package),
340            package,
341            kind: GeneratedFileKind::PackageMod,
342            content,
343        });
344    }
345
346    Ok(files)
347}
348
349/// Generate a `CodeGeneratorResponse` from a protoc `CodeGeneratorRequest`.
350///
351/// This is the entry point for the protoc plugin (`protoc-gen-connect-rust`).
352/// It parses the comma-separated `request.parameter` into [`Options`] and
353/// delegates to [`generate_services`] — service stubs only. Callers must
354/// run `protoc-gen-buffa` (or equivalent) separately for message types.
355///
356/// # Output
357///
358/// Per proto with at least one `service`: a `<stem>.__connect.rs` content
359/// file with the service stubs. Per package with at least one such proto:
360/// a `<pkg>.mod.rs` stitcher that `include!`s the content files. The
361/// stitcher filename intentionally matches `protoc-gen-buffa`'s, so run
362/// this plugin into a separate output directory and use
363/// `protoc-gen-buffa-packaging` to wire both trees, as shown in this
364/// repo's `buf.gen.yaml` examples.
365///
366/// Under `file_per_package` the per-proto split is collapsed: one
367/// `<dotted.pkg>.rs` per package with all service stubs inlined, no
368/// per-proto content files, and no stitcher. **Drop the
369/// `protoc-gen-buffa-packaging` invocations from your `buf.gen.yaml`
370/// under this layout** — there are no per-file content files or
371/// stitchers for it to wire, and leaving it in produces dead `mod.rs`
372/// output without an error. Either let your downstream build tool
373/// synthesise the module tree from `<dotted.package>.rs` filenames (BSR
374/// cargo SDKs do this automatically) or hand-write the `mod.rs`. See
375/// [`generate_services`].
376///
377/// A worked `file_per_package` `buf.gen.yaml`:
378///
379/// ```yaml
380/// version: v2
381/// plugins:
382///   - local: protoc-gen-buffa
383///     out: src/gen/buffa
384///     opt: [file_per_package]
385///   - local: protoc-gen-connect-rust
386///     out: src/gen/connect
387///     opt: [file_per_package, buffa_module=crate::gen::buffa]
388/// ```
389///
390/// You then mount each tree with a hand-written `mod.rs` (or let BSR's
391/// cargo SDK pipeline do it):
392///
393/// ```rust,ignore
394/// pub mod buffa { /* one `pub mod <pkg> { include!("<pkg>.rs"); }` per package */ }
395/// pub mod connect { /* same, pointing at src/gen/connect */ }
396/// ```
397///
398/// # Recognized options
399///
400/// - `buffa_module=<rust_path>` — where you mounted the buffa-generated
401///   module tree (e.g. `buffa_module=crate::proto`). Shorthand for
402///   `extern_path=.=<rust_path>`. This is the option most local users want.
403/// - `extern_path=<proto>=<rust>` — map a specific proto package prefix
404///   to a Rust module path. Repeatable; longest-prefix-match wins.
405///   `extern_path=.=<path>` is the catch-all (equivalent to `buffa_module`).
406///   At least one catch-all mapping is required so every type resolves.
407/// - `file_per_package` — emit one `<dotted.pkg>.rs` per proto package
408///   instead of the per-proto split + stitcher. Set `protoc-gen-buffa`'s
409///   own `file_per_package` option to the same value — the BSR/`tonic`
410///   `lib.rs` synthesis assumes both plugins use the same filename
411///   convention; mismatched settings produce a valid but asymmetric
412///   layout you would have to wire by hand. Keep using a dedicated
413///   output directory (the documented split-path setup already does
414///   this) — the filename matches `protoc-gen-buffa`'s and would
415///   silently overwrite in a shared one. See
416///   [`CodeGenConfig::file_per_package`] for the `strategy: directory`
417///   constraint.
418/// - `strict_utf8_mapping` — see [`CodeGenConfig::strict_utf8_mapping`].
419/// - `no_json` — disable `serde` derives on generated message types.
420///   Ignored in this plugin (no message types emitted); accepted for
421///   compatibility with the unified path.
422/// - `no_register_fn` — suppress the per-file
423///   `register_types(&mut TypeRegistry)` aggregator. See
424///   [`CodeGenConfig::emit_register_fn`]. Ignored in this plugin (no message
425///   types emitted); accepted for compatibility with the unified path.
426pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
427    let mut options = Options::default();
428
429    if let Some(ref param) = request.parameter {
430        for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
431            if let Some(value) = opt.strip_prefix("buffa_module=") {
432                let rust = value.trim();
433                if rust.is_empty() {
434                    anyhow::bail!(
435                        "buffa_module requires a non-empty path, \
436                         e.g. buffa_module=crate::proto"
437                    );
438                }
439                options
440                    .buffa
441                    .extern_paths
442                    .push((".".into(), rust.to_string()));
443            } else if let Some(value) = opt.strip_prefix("extern_path=") {
444                // value is "<proto_path>=<rust_path>"
445                let (proto, rust) = value.split_once('=').ok_or_else(|| {
446                    anyhow::anyhow!(
447                        "invalid extern_path format {value:?}, expected \
448                         extern_path=.proto.pkg=::rust::path"
449                    )
450                })?;
451                let proto = proto.trim();
452                let rust = rust.trim();
453                if proto.is_empty() || rust.is_empty() {
454                    anyhow::bail!(
455                        "invalid extern_path format {value:?}, expected \
456                         extern_path=.proto.pkg=::rust::path (both sides non-empty)"
457                    );
458                }
459                let mut proto = proto.to_string();
460                if !proto.starts_with('.') {
461                    proto.insert(0, '.');
462                }
463                options.buffa.extern_paths.push((proto, rust.to_string()));
464            } else {
465                match opt {
466                    "file_per_package" => options.buffa.file_per_package = true,
467                    "strict_utf8_mapping" => options.buffa.strict_utf8_mapping = true,
468                    "no_json" => options.buffa.generate_json = false,
469                    "no_register_fn" => options.buffa.emit_register_fn = false,
470                    _ => {
471                        return Err(anyhow::anyhow!(
472                            "unknown plugin option: {opt:?}. Supported: \
473                             buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
474                             file_per_package, strict_utf8_mapping, no_json, \
475                             no_register_fn"
476                        ));
477                    }
478                }
479            }
480        }
481    }
482
483    let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
484
485    let files: Vec<CodeGeneratorResponseFile> = generated
486        .into_iter()
487        .map(|g| CodeGeneratorResponseFile {
488            name: Some(g.name),
489            content: Some(g.content),
490            ..Default::default()
491        })
492        .collect();
493
494    Ok(CodeGeneratorResponse {
495        supported_features: Some(feature_flags()),
496        minimum_edition: Some(EDITION_2023),
497        maximum_edition: Some(EDITION_2023),
498        file: files,
499        ..Default::default()
500    })
501}
502
503/// Feature flags we support (bitmask). See
504/// `google.protobuf.compiler.CodeGeneratorResponse.Feature`.
505fn feature_flags() -> u64 {
506    const FEATURE_PROTO3_OPTIONAL: u64 = 1;
507    const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
508    FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
509}
510
511/// Edition 2023 numeric value. buffa-codegen handles proto2/proto3/edition-2023;
512/// we declare 2023 as both min and max.
513const EDITION_2023: i32 = 1000;
514
515/// Format a TokenStream into a Rust source string via prettyplease.
516fn format_token_stream(tokens: &TokenStream) -> Result<String> {
517    let file = syn::parse2::<syn::File>(tokens.clone())
518        .map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
519    Ok(prettyplease::unparse(&file))
520}
521
522/// Emit `#[doc = " line"]` attributes for each line of `text`.
523///
524/// prettyplease renders `#[doc = "X"]` as `///X` verbatim (no space inserted);
525/// to get `/// X` the string must already start with a space. This helper
526/// prefixes each line with a space so the unparsed output matches hand-written
527/// doc comment style.
528///
529/// Leaves blank lines as-is (→ `///`) so paragraph breaks render correctly.
530fn doc_attrs(text: &str) -> TokenStream {
531    let lines: Vec<String> = text
532        .lines()
533        .map(|l| {
534            if l.is_empty() {
535                String::new()
536            } else {
537                format!(" {l}")
538            }
539        })
540        .collect();
541    quote! { #(#[doc = #lines])* }
542}
543
544// ---------------------------------------------------------------------------
545// Type path resolution
546// ---------------------------------------------------------------------------
547
548/// Resolves fully-qualified protobuf type names to Rust type-path tokens
549/// relative to the current file's package module.
550///
551/// Wraps [`buffa_codegen::context::CodeGenContext`] via `for_generate()` so
552/// service method input/output types resolve to the same paths buffa-codegen
553/// emits for message fields — including cross-package (`super::foo::Bar`),
554/// WKT extern paths (`::buffa_types::google::protobuf::Empty`), and nested
555/// types (`outer::Inner`). Zero drift with buffa's own generation.
556struct TypeResolver<'a> {
557    ctx: buffa_codegen::context::CodeGenContext<'a>,
558    /// When true, every resolved path must be absolute (`::foo` or
559    /// `crate::foo`). Paths that would resolve to `super::`-relative or
560    /// bare-ident forms produce an error instead. Used by
561    /// [`generate_services`] to enforce that service stubs reference
562    /// message types via `extern_path` only.
563    require_extern: bool,
564}
565
566impl<'a> TypeResolver<'a> {
567    fn new(
568        proto_file: &'a [FileDescriptorProto],
569        file_to_generate: &[String],
570        config: &'a buffa_codegen::CodeGenConfig,
571        require_extern: bool,
572    ) -> Self {
573        Self {
574            ctx: buffa_codegen::context::CodeGenContext::for_generate(
575                proto_file,
576                file_to_generate,
577                config,
578            ),
579            require_extern,
580        }
581    }
582
583    /// Resolve a proto FQN (e.g. `.google.protobuf.Empty`) to a Rust type-path
584    /// string relative to `current_package`.
585    ///
586    /// In `require_extern` mode, errors if the path is not absolute or the
587    /// type is absent from the descriptor set. Otherwise falls back to the
588    /// bare type name for unknown types (rustc will point at the use site).
589    fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
590        match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
591            Some(path) => {
592                self.check_extern_coverage(proto_fqn, &path)?;
593                Ok(path)
594            }
595            None => self.fallback_unresolved(proto_fqn).map(str::to_string),
596        }
597    }
598
599    /// In `require_extern` mode, fail if `path_prefix` isn't an absolute or
600    /// crate-rooted path (i.e., the type wasn't covered by an extern_path
601    /// mapping). No-op otherwise.
602    fn check_extern_coverage(&self, proto_fqn: &str, path_prefix: &str) -> Result<()> {
603        if self.require_extern
604            && !path_prefix.starts_with("::")
605            && !path_prefix.starts_with("crate::")
606        {
607            anyhow::bail!(
608                "type {proto_fqn} is not covered by any extern_path mapping. \
609                 Add extern_path=.=<your_buffa_module> (e.g. \
610                 extern_path=.=crate::proto) to the plugin opts."
611            );
612        }
613        Ok(())
614    }
615
616    /// Fallback when a FQN is absent from the descriptor set: error in
617    /// `require_extern` mode, otherwise return the bare type name (rustc
618    /// will point at the use site if it's wrong).
619    fn fallback_unresolved<'f>(&self, proto_fqn: &'f str) -> Result<&'f str> {
620        if self.require_extern {
621            anyhow::bail!("type {proto_fqn} not found in descriptor set (missing proto import?)");
622        }
623        Ok(bare_type_name(proto_fqn))
624    }
625
626    /// Resolve a proto FQN to Rust type-path tokens.
627    fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
628        let path = self.resolve_path(proto_fqn, current_package)?;
629        Ok(rust_path_to_tokens(&path))
630    }
631
632    /// Resolve a proto FQN to its **view** Rust type-path tokens.
633    ///
634    /// Under buffa's `__buffa::` ancillary tree, view types live at
635    /// `<to-package>::__buffa::view::<within-package>View`, so this uses
636    /// `CodeGenContext::rust_type_relative_split` to find the package
637    /// boundary and inserts the sentinel path between the two halves.
638    fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
639        use buffa_codegen::context::SENTINEL_MOD;
640        let (to_package, within) =
641            match self
642                .ctx
643                .rust_type_relative_split(proto_fqn, current_package, 0)
644            {
645                Some(s) => {
646                    self.check_extern_coverage(proto_fqn, &s.to_package)?;
647                    (s.to_package, s.within_package)
648                }
649                None => (
650                    String::new(),
651                    self.fallback_unresolved(proto_fqn)?.to_string(),
652                ),
653            };
654        let prefix = if to_package.is_empty() {
655            format!("{SENTINEL_MOD}::view")
656        } else {
657            format!("{to_package}::{SENTINEL_MOD}::view")
658        };
659        Ok(rust_path_to_tokens(&format!("{prefix}::{within}View")))
660    }
661}
662
663/// Last segment of a proto FQN, e.g. `.google.protobuf.Empty` → `"Empty"`.
664/// Fallback for types absent from the resolver context.
665fn bare_type_name(proto_fqn: &str) -> &str {
666    proto_fqn
667        .strip_prefix('.')
668        .unwrap_or(proto_fqn)
669        .rsplit('.')
670        .next()
671        .unwrap_or(proto_fqn)
672}
673
674// ---------------------------------------------------------------------------
675// ConnectRPC service code generation
676// ---------------------------------------------------------------------------
677
678/// Generate ConnectRPC service bindings for a file.
679/// Per-batch dedup state passed through the per-file emission loop.
680#[derive(Default)]
681struct BatchState {
682    /// Proto FQNs of output types whose `Encodable<M>` view impls have
683    /// already been emitted (global; impls are not module-scoped).
684    encodable_seen: std::collections::BTreeSet<String>,
685    /// `(package, proto FQN)` of input/output types whose
686    /// `Owned#{Msg}View` alias has already been emitted (per package
687    /// module; aliases are module-scoped).
688    alias_seen: std::collections::BTreeSet<(String, String)>,
689    /// `(package, alias_name)` pairs where two or more distinct FQNs would
690    /// produce the same `Owned<Msg>View` alias in the same target Rust
691    /// module — e.g. a service file that defines its own `MyMessage` and
692    /// also references an imported `.api.v1.foo.bar.MyMessage` (issue
693    /// [#75]). The alias is suppressed for every member of a colliding
694    /// set; trait method signatures inline the
695    /// `::buffa::view::OwnedView<…<'static>>` form for those types
696    /// instead. Aliases for non-colliding types (the common case,
697    /// including same-package and well-known types like
698    /// `.google.protobuf.Empty`) are unaffected.
699    ///
700    /// [#75]: https://github.com/anthropics/connect-rust/issues/75
701    colliding_aliases: std::collections::BTreeSet<(String, String)>,
702}
703
704fn generate_connect_services(
705    file: &FileDescriptorProto,
706    resolver: &TypeResolver<'_>,
707    batch: &mut BatchState,
708) -> Result<TokenStream> {
709    let mut tokens = TokenStream::new();
710
711    // All types in generated code use fully qualified paths (e.g.
712    // `::std::sync::Arc`, `::connectrpc::Context`) so that multiple service
713    // files can be `include!`d into the same module without E0252 duplicate
714    // import errors.
715
716    tokens.extend(generate_owned_view_aliases(file, resolver, batch)?);
717    tokens.extend(generate_encodable_view_impls(file, resolver, batch)?);
718
719    for service in &file.service {
720        tokens.extend(generate_service(file, service, resolver, batch)?);
721    }
722
723    Ok(tokens)
724}
725
726/// `Owned#{Msg}View` alias name for a proto FQN, e.g.
727/// `.example.v1.Record` → `OwnedRecordView`.
728fn owned_view_alias_ident(fqn: &str) -> Ident {
729    format_ident!("Owned{}View", bare_type_name(fqn).to_upper_camel_case())
730}
731
732/// True iff emitting `Owned<Msg>View` for `proto_fqn` in `current_package`
733/// would collide with another distinct FQN's alias in the same module
734/// (issue [#75]). Cross-package types whose short name is unique in this
735/// package's alias set keep their alias; only the colliding set is
736/// suppressed in favour of the inlined `OwnedView<…<'static>>` form.
737///
738/// [#75]: https://github.com/anthropics/connect-rust/issues/75
739fn alias_collides(batch: &BatchState, current_package: &str, proto_fqn: &str) -> bool {
740    let alias = owned_view_alias_ident(proto_fqn).to_string();
741    batch
742        .colliding_aliases
743        .contains(&(current_package.to_string(), alias))
744}
745
746/// Trait-method input-type tokens for an RPC: either the local
747/// `Owned<Msg>View` alias (the common case) or the inlined
748/// `::buffa::view::OwnedView<Path::To::<Msg>View<'static>>` form for
749/// types whose alias would collide with another type in the same target
750/// Rust module (issue #75). The inlined form mirrors what the generated
751/// client method signatures already emit for response types.
752fn owned_view_input_arg_type(
753    resolver: &TypeResolver<'_>,
754    batch: &BatchState,
755    proto_fqn: &str,
756    current_package: &str,
757) -> Result<TokenStream> {
758    if alias_collides(batch, current_package, proto_fqn) {
759        let view = resolver.rust_view_type(proto_fqn, current_package)?;
760        Ok(quote!(::buffa::view::OwnedView<#view<'static>>))
761    } else {
762        let alias = owned_view_alias_ident(proto_fqn);
763        Ok(quote!(#alias))
764    }
765}
766
767/// Walk every service's method input/output FQNs across `file_to_generate`
768/// and identify `(package, alias_ident)` pairs where two or more distinct
769/// FQNs would produce the same `Owned<Msg>View` alias in the same target
770/// Rust module. Caller stores the result in [`BatchState::colliding_aliases`].
771///
772/// This pre-pass is what makes the alias emission collision-aware: a
773/// per-file walk can't see same-short-name FQNs from sibling files in the
774/// same package, but the stitcher mounts both into one module so the
775/// collision is real (issue [#75]).
776///
777/// [#75]: https://github.com/anthropics/connect-rust/issues/75
778fn collect_alias_collisions(
779    proto_file: &[FileDescriptorProto],
780    file_to_generate: &[String],
781) -> std::collections::BTreeSet<(String, String)> {
782    use std::collections::BTreeMap;
783    // (package, alias_name) -> first FQN seen; subsequent distinct FQNs
784    // mark the key as colliding.
785    let mut first_seen: BTreeMap<(String, String), String> = BTreeMap::new();
786    let mut colliding: std::collections::BTreeSet<(String, String)> =
787        std::collections::BTreeSet::new();
788
789    for file_name in file_to_generate {
790        let Some(file) = proto_file
791            .iter()
792            .find(|f| f.name.as_deref() == Some(file_name.as_str()))
793        else {
794            continue;
795        };
796        let package = file.package.clone().unwrap_or_default();
797        for service in &file.service {
798            for m in &service.method {
799                for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
800                    .into_iter()
801                    .flatten()
802                {
803                    let alias = owned_view_alias_ident(fqn).to_string();
804                    let key = (package.clone(), alias);
805                    match first_seen.get(&key) {
806                        Some(prev) if prev != fqn => {
807                            colliding.insert(key);
808                        }
809                        Some(_) => {} // same FQN — fine, dedup catches it
810                        None => {
811                            first_seen.insert(key, fqn.to_string());
812                        }
813                    }
814                }
815            }
816        }
817    }
818    colliding
819}
820
821/// Emit `pub type Owned#{Msg}View = OwnedView<#{Msg}View<'static>>;` for
822/// every distinct RPC input/output type referenced by services in this
823/// file. The alias is what handlers see in trait method signatures and
824/// what users write in their `impl` blocks.
825///
826/// Aliases whose name would collide with another distinct type's alias
827/// in the same target package (per [`BatchState::colliding_aliases`]) are
828/// suppressed — the trait method signature inlines the
829/// `OwnedView<…<'static>>` form for those types instead (see
830/// [`owned_view_input_arg_type`]). This is the issue [#75] fix; the
831/// non-colliding common case (including well-known types like
832/// `.google.protobuf.Empty`) keeps its alias.
833///
834/// Deduped on `(package, fqn)` across the batch so two files in the same
835/// package don't both emit the alias (E0428).
836///
837/// [#75]: https://github.com/anthropics/connect-rust/issues/75
838fn generate_owned_view_aliases(
839    file: &FileDescriptorProto,
840    resolver: &TypeResolver<'_>,
841    batch: &mut BatchState,
842) -> Result<TokenStream> {
843    let package = file.package.as_deref().unwrap_or("");
844    let mut out = TokenStream::new();
845    for service in &file.service {
846        for m in &service.method {
847            for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
848                .into_iter()
849                .flatten()
850            {
851                if alias_collides(batch, package, fqn) {
852                    continue;
853                }
854                if !batch
855                    .alias_seen
856                    .insert((package.to_string(), fqn.to_string()))
857                {
858                    continue;
859                }
860                let alias = owned_view_alias_ident(fqn);
861                let view = resolver.rust_view_type(fqn, package)?;
862                let doc = format!(
863                    "Shorthand for `OwnedView<{}View<'static>>`.",
864                    bare_type_name(fqn).to_upper_camel_case()
865                );
866                out.extend(quote! {
867                    #[doc = #doc]
868                    pub type #alias = ::buffa::view::OwnedView<#view<'static>>;
869                });
870            }
871        }
872    }
873    Ok(out)
874}
875
876/// Emit `impl Encodable<M> for MView<'_>` and
877/// `impl Encodable<M> for OwnedView<MView<'static>>` for every distinct
878/// RPC output type not already in `batch.encodable_seen` (proto FQN).
879///
880/// These can't be runtime blankets (the `M: Message + Serialize` blanket
881/// in `connectrpc::response` would conflict by coherence), so they're
882/// emitted per concrete type. Orphan rules allow it because `M` (a local
883/// type) appears in the trait parameters.
884///
885/// `batch.encodable_seen` is owned by the caller's batch loop so an
886/// output type referenced from multiple input files only gets one impl
887/// pair (the stitcher would otherwise hit E0119).
888///
889/// Skipped for output types that resolve to an absolute (`::`) extern
890/// path, since those are foreign and would violate orphan rules.
891fn generate_encodable_view_impls(
892    file: &FileDescriptorProto,
893    resolver: &TypeResolver<'_>,
894    batch: &mut BatchState,
895) -> Result<TokenStream> {
896    let package = file.package.as_deref().unwrap_or("");
897    let mut out = TokenStream::new();
898    for service in &file.service {
899        for m in &service.method {
900            let fqn = m.output_type.as_deref().unwrap_or("");
901            if !batch.encodable_seen.insert(fqn.to_string()) {
902                continue;
903            }
904            let path = resolver.resolve_path(fqn, package)?;
905            // Skip foreign types (extern_path → `::crate_name::...`): the
906            // impl would be an orphan in the user's crate.
907            if path.starts_with("::") {
908                continue;
909            }
910            let owned = resolver.rust_type(fqn, package)?;
911            let view = resolver.rust_view_type(fqn, package)?;
912            out.extend(quote! {
913                impl ::connectrpc::Encodable<#owned> for #view<'_> {
914                    fn encode(&self, codec: ::connectrpc::CodecFormat)
915                        -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
916                    {
917                        ::connectrpc::__codegen::encode_view_body(self, codec)
918                    }
919                }
920                impl ::connectrpc::Encodable<#owned> for ::buffa::view::OwnedView<#view<'static>> {
921                    fn encode(&self, codec: ::connectrpc::CodecFormat)
922                        -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
923                    {
924                        ::connectrpc::__codegen::encode_view_body(&**self, codec)
925                    }
926                }
927            });
928        }
929    }
930    Ok(out)
931}
932
933/// Generate code for a single service.
934/// Reject RPC method sets whose generated Rust identifiers collide.
935///
936/// Each proto method `Foo` produces both `foo` and `foo_with_options` on the
937/// client. Two methods that normalize to the same snake_case name (e.g.
938/// `GetFoo` and `get_foo`), or one whose snake form equals another's
939/// `_with_options` form, would emit duplicate definitions and fail to
940/// compile with an error pointing at generated code rather than the proto.
941fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
942    let mut seen: HashMap<String, String> = HashMap::new();
943    for m in &service.method {
944        let proto_name = m.name.as_deref().unwrap_or("");
945        let snake = proto_name.to_snake_case();
946        let with_opts = format!("{snake}_with_options");
947        for ident in [snake.as_str(), with_opts.as_str()] {
948            if let Some(prev) = seen.get(ident) {
949                anyhow::bail!(
950                    "service {service_name}: RPC methods {prev:?} and {proto_name:?} \
951                     both generate Rust identifier `{ident}`; rename one in the proto"
952                );
953            }
954        }
955        seen.insert(snake, proto_name.to_string());
956        seen.insert(with_opts, proto_name.to_string());
957    }
958    Ok(())
959}
960
961fn generate_service(
962    file: &FileDescriptorProto,
963    service: &ServiceDescriptorProto,
964    resolver: &TypeResolver<'_>,
965    batch: &BatchState,
966) -> Result<TokenStream> {
967    let package = file.package.as_deref().unwrap_or("");
968    let service_name = service.name.as_deref().unwrap_or("");
969    check_method_collisions(service_name, service)?;
970    // Empty package is valid proto; the fully-qualified service name is just
971    // `ServiceName`, not `.ServiceName` (which would break interop).
972    let full_service_name = if package.is_empty() {
973        service_name.to_string()
974    } else {
975        format!("{package}.{service_name}")
976    };
977    let service_upper = service_name.to_upper_camel_case();
978    // `Self` is the only PascalCase Rust keyword, and cannot be a raw ident;
979    // suffix it so `service Self {}` (accepted by protoc) generates a valid
980    // trait. The suffixed derivatives below are already keyword-safe.
981    let trait_name = if service_upper == "Self" {
982        format_ident!("Self_")
983    } else {
984        format_ident!("{}", service_upper)
985    };
986    let ext_trait_name = format_ident!("{}Ext", service_upper);
987    let client_name = format_ident!("{}Client", service_upper);
988    let server_name = format_ident!("{}Server", service_upper);
989    let service_name_const = format_ident!(
990        "{}_SERVICE_NAME",
991        service_name.to_snake_case().to_uppercase()
992    );
993
994    // Get service documentation and append async impl guidance
995    let service_doc = get_service_comment(file, service).unwrap_or_default();
996    let base_doc = if service_doc.is_empty() {
997        format!("Server trait for {service_name}.")
998    } else {
999        service_doc
1000    };
1001    let full_doc = format!(
1002        "{base_doc}\n\n\
1003         # Implementing handlers\n\n\
1004         Handlers receive requests as `OwnedFooView` (an alias for\n\
1005         `OwnedView<FooView<'static>>`), which gives zero-copy borrowed access\n\
1006         to fields (e.g. `request.name` is a `&str` into the decoded buffer).\n\
1007         The view can be held across `.await` points. When two RPC types in\n\
1008         the same package would alias to the same `Owned<…>View` name (e.g.\n\
1009         a local message plus an imported one with the same short name), the\n\
1010         alias is suppressed for both and the request type is spelled as\n\
1011         `OwnedView<…View<'static>>` directly in the trait signature.\n\n\
1012         Implement methods with plain `async fn`; the returned future satisfies\n\
1013         the `Send` bound automatically. See the\n\
1014         [buffa user guide](https://github.com/anthropics/buffa/blob/main/docs/guide.md#ownedview-in-async-trait-implementations)\n\
1015         for zero-copy access patterns and when `to_owned_message()` is needed.\n\n\
1016         The `impl Encodable<Out>` return bound accepts the owned `Out`, the\n\
1017         generated `OutView<'_>` / `OwnedOutView`, or\n\
1018         [`MaybeBorrowed`](::connectrpc::MaybeBorrowed). View bodies are not\n\
1019         emitted for output types mapped via `extern_path` (the impl would be\n\
1020         an orphan); return owned for WKT/extern outputs."
1021    );
1022    let service_doc_tokens = doc_attrs(&full_doc);
1023
1024    // Generate trait methods
1025    let trait_methods: Vec<TokenStream> = service
1026        .method
1027        .iter()
1028        .map(|m| generate_trait_method(file, service, m, resolver, batch, package))
1029        .collect::<Result<Vec<_>>>()?;
1030
1031    // Generate route registrations for extension trait
1032    let route_registrations: Vec<TokenStream> = service
1033        .method
1034        .iter()
1035        .map(|m| {
1036            let method_name = m.name.as_deref().unwrap_or("");
1037            let method_snake = make_field_ident(&method_name.to_snake_case());
1038
1039            let client_streaming = m.client_streaming.unwrap_or(false);
1040            let server_streaming = m.server_streaming.unwrap_or(false);
1041
1042            if server_streaming && !client_streaming {
1043                // Server streaming method
1044                quote! {
1045                    .route_view_server_stream(
1046                        #service_name_const,
1047                        #method_name,
1048                        ::connectrpc::view_streaming_handler_fn({
1049                            let svc = ::std::sync::Arc::clone(&self);
1050                            move |ctx, req| {
1051                                let svc = ::std::sync::Arc::clone(&svc);
1052                                async move { svc.#method_snake(ctx, req).await }
1053                            }
1054                        }),
1055                    )
1056                }
1057            } else if client_streaming && !server_streaming {
1058                // Client streaming method
1059                let output_type = resolver
1060                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1061                    .unwrap();
1062                quote! {
1063                    .route_view_client_stream(
1064                        #service_name_const,
1065                        #method_name,
1066                        ::connectrpc::view_client_streaming_handler_fn({
1067                            let svc = ::std::sync::Arc::clone(&self);
1068                            move |ctx, req, format| {
1069                                let svc = ::std::sync::Arc::clone(&svc);
1070                                async move {
1071                                    svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1072                                }
1073                            }
1074                        }),
1075                    )
1076                }
1077            } else if client_streaming && server_streaming {
1078                // Bidi streaming method
1079                quote! {
1080                    .route_view_bidi_stream(
1081                        #service_name_const,
1082                        #method_name,
1083                        ::connectrpc::view_bidi_streaming_handler_fn({
1084                            let svc = ::std::sync::Arc::clone(&self);
1085                            move |ctx, req| {
1086                                let svc = ::std::sync::Arc::clone(&svc);
1087                                async move { svc.#method_snake(ctx, req).await }
1088                            }
1089                        }),
1090                    )
1091                }
1092            } else {
1093                // Unary method
1094                let is_idempotent = m
1095                    .options
1096                    .idempotency_level
1097                    .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1098                    .unwrap_or(false);
1099
1100                let route_method = if is_idempotent {
1101                    quote! { route_view_idempotent }
1102                } else {
1103                    quote! { route_view }
1104                };
1105                let output_type = resolver
1106                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1107                    .unwrap();
1108
1109                quote! {
1110                    .#route_method(
1111                        #service_name_const,
1112                        #method_name,
1113                        {
1114                            let svc = ::std::sync::Arc::clone(&self);
1115                            ::connectrpc::view_handler_fn(move |ctx, req, format| {
1116                                let svc = ::std::sync::Arc::clone(&svc);
1117                                async move {
1118                                    svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1119                                }
1120                            })
1121                        },
1122                    )
1123                }
1124            }
1125        })
1126        .collect();
1127
1128    // Generate client methods
1129    let client_methods: Vec<TokenStream> = service
1130        .method
1131        .iter()
1132        .map(|m| {
1133            generate_client_method(
1134                &service_name_const,
1135                &full_service_name,
1136                m,
1137                resolver,
1138                package,
1139            )
1140        })
1141        .collect::<Result<Vec<_>>>()?;
1142
1143    // Generate monomorphic FooServiceServer<T> dispatcher.
1144    let service_server = generate_service_server(
1145        &full_service_name,
1146        &trait_name,
1147        &server_name,
1148        service,
1149        resolver,
1150        package,
1151    )?;
1152
1153    // Example method name for client doc
1154    let example_method = service
1155        .method
1156        .first()
1157        .and_then(|m| m.name.as_deref())
1158        .map(|n| make_field_ident(&n.to_snake_case()).to_string())
1159        .unwrap_or_else(|| "method".to_string());
1160
1161    // Build client doc comment with interpolated example method
1162    let client_name_str = client_name.to_string();
1163    let client_doc = format!(
1164        r#"Client for this service.
1165
1166Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
1167`Http2Connection` — it has honest `poll_ready` and composes with
1168`tower::balance` for multi-connection load balancing. For **Connect
1169over HTTP/1.1** (or unknown protocol), use `HttpClient`.
1170
1171# Example (gRPC / HTTP/2)
1172
1173```rust,ignore
1174use connectrpc::client::{{Http2Connection, ClientConfig}};
1175use connectrpc::Protocol;
1176
1177let uri: http::Uri = "http://localhost:8080".parse()?;
1178let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
1179let config = ClientConfig::new(uri).protocol(Protocol::Grpc);
1180
1181let client = {client_name_str}::new(conn, config);
1182let response = client.{example_method}(request).await?;
1183```
1184
1185# Example (Connect / HTTP/1.1 or ALPN)
1186
1187```rust,ignore
1188use connectrpc::client::{{HttpClient, ClientConfig}};
1189
1190let http = HttpClient::plaintext();  // cleartext http:// only
1191let config = ClientConfig::new("http://localhost:8080".parse()?);
1192
1193let client = {client_name_str}::new(http, config);
1194let response = client.{example_method}(request).await?;
1195```
1196
1197# Working with the response
1198
1199Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
1200The `OwnedView` derefs to the view, so field access is zero-copy:
1201
1202```rust,ignore
1203let resp = client.{example_method}(request).await?.into_view();
1204let name: &str = resp.name;  // borrow into the response buffer
1205```
1206
1207If you need the owned struct (e.g. to store or pass by value), use
1208[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
1209
1210```rust,ignore
1211let owned = client.{example_method}(request).await?.into_owned();
1212```"#
1213    );
1214    let client_doc_tokens = doc_attrs(&client_doc);
1215
1216    Ok(quote! {
1217        // -----------------------------------------------------------------------------
1218        // #service_name
1219        // -----------------------------------------------------------------------------
1220
1221        /// Full service name for this service.
1222        pub const #service_name_const: &str = #full_service_name;
1223
1224        #service_doc_tokens
1225        #[allow(clippy::type_complexity)]
1226        pub trait #trait_name: Send + Sync + 'static {
1227            #(#trait_methods)*
1228        }
1229
1230        /// Extension trait for registering a service implementation with a Router.
1231        ///
1232        /// This trait is automatically implemented for all types that implement the service trait.
1233        ///
1234        /// # Example
1235        ///
1236        /// ```rust,ignore
1237        /// use std::sync::Arc;
1238        ///
1239        /// let service = Arc::new(MyServiceImpl);
1240        /// let router = service.register(Router::new());
1241        /// ```
1242        pub trait #ext_trait_name: #trait_name {
1243            /// Register this service implementation with a Router.
1244            ///
1245            /// Takes ownership of the `Arc<Self>` and returns a new Router with
1246            /// this service's methods registered.
1247            fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router;
1248        }
1249
1250        impl<S: #trait_name> #ext_trait_name for S {
1251            fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router {
1252                router
1253                    #(#route_registrations)*
1254            }
1255        }
1256
1257        #service_server
1258
1259        #client_doc_tokens
1260        #[derive(Clone)]
1261        pub struct #client_name<T> {
1262            transport: T,
1263            config: ::connectrpc::client::ClientConfig,
1264        }
1265
1266        impl<T> #client_name<T>
1267        where
1268            T: ::connectrpc::client::ClientTransport,
1269            <T::ResponseBody as ::http_body::Body>::Error: ::std::fmt::Display,
1270        {
1271            /// Create a new client with the given transport and configuration.
1272            pub fn new(transport: T, config: ::connectrpc::client::ClientConfig) -> Self {
1273                Self { transport, config }
1274            }
1275
1276            /// Get the client configuration.
1277            pub fn config(&self) -> &::connectrpc::client::ClientConfig {
1278                &self.config
1279            }
1280
1281            /// Get a mutable reference to the client configuration.
1282            pub fn config_mut(&mut self) -> &mut ::connectrpc::client::ClientConfig {
1283                &mut self.config
1284            }
1285
1286            #(#client_methods)*
1287        }
1288    })
1289}
1290
1291/// Generate a monomorphic `FooServiceServer<T>` struct and its `Dispatcher` impl.
1292///
1293/// This is the fast-path alternative to `FooServiceExt::register(Router)`: instead
1294/// of type-erasing each method behind `Arc<dyn ErasedHandler>` and looking them up
1295/// in a `HashMap`, this struct dispatches via a compile-time `match` on method name
1296/// with no trait objects or hash lookups in the hot path.
1297fn generate_service_server(
1298    full_service_name: &str,
1299    trait_name: &proc_macro2::Ident,
1300    server_name: &proc_macro2::Ident,
1301    service: &ServiceDescriptorProto,
1302    resolver: &TypeResolver<'_>,
1303    package: &str,
1304) -> Result<TokenStream> {
1305    // Path prefix matched by `dispatch` / `call_*`: "pkg.Service/"
1306    let path_prefix = format!("{full_service_name}/");
1307
1308    // Per-method match arms for `lookup(path)`.
1309    let lookup_arms: Vec<TokenStream> = service
1310        .method
1311        .iter()
1312        .map(|m| {
1313            let method_name = m.name.as_deref().unwrap_or("");
1314            let client_streaming = m.client_streaming.unwrap_or(false);
1315            let server_streaming = m.server_streaming.unwrap_or(false);
1316            let is_idempotent = m
1317                .options
1318                .idempotency_level
1319                .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1320                .unwrap_or(false);
1321
1322            let desc = if client_streaming && server_streaming {
1323                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::bidi_streaming() }
1324            } else if client_streaming {
1325                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::client_streaming() }
1326            } else if server_streaming {
1327                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::server_streaming() }
1328            } else {
1329                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::unary(#is_idempotent) }
1330            };
1331            quote! { #method_name => Some(#desc), }
1332        })
1333        .collect();
1334
1335    // Per-kind match arms for the four `call_*` methods.
1336    // Each `call_*` only includes arms for methods of the matching kind; other
1337    // paths fall through to `unimplemented_*` (the caller checked `lookup()`
1338    // first, so this is a defensive-only branch).
1339    let mut call_unary_arms: Vec<TokenStream> = Vec::new();
1340    let mut call_ss_arms: Vec<TokenStream> = Vec::new();
1341    let mut call_cs_arms: Vec<TokenStream> = Vec::new();
1342    let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
1343
1344    for m in &service.method {
1345        let method_name = m.name.as_deref().unwrap_or("");
1346        let method_snake = make_field_ident(&method_name.to_snake_case());
1347        let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
1348        let output_type = resolver.rust_type(m.output_type.as_deref().unwrap_or(""), package)?;
1349        let cs = m.client_streaming.unwrap_or(false);
1350        let ss = m.server_streaming.unwrap_or(false);
1351
1352        if cs && ss {
1353            // Bidi streaming
1354            call_bidi_arms.push(quote! {
1355                #method_name => {
1356                    let svc = ::std::sync::Arc::clone(&self.inner);
1357                    Box::pin(async move {
1358                        let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
1359                        let resp = svc.#method_snake(ctx, req_stream).await?;
1360                        Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream(s, format)))
1361                    })
1362                }
1363            });
1364        } else if cs {
1365            // Client streaming
1366            call_cs_arms.push(quote! {
1367                #method_name => {
1368                    let svc = ::std::sync::Arc::clone(&self.inner);
1369                    Box::pin(async move {
1370                        let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
1371                        svc.#method_snake(ctx, req_stream).await?.encode::<#output_type>(format)
1372                    })
1373                }
1374            });
1375        } else if ss {
1376            // Server streaming
1377            call_ss_arms.push(quote! {
1378                #method_name => {
1379                    let svc = ::std::sync::Arc::clone(&self.inner);
1380                    Box::pin(async move {
1381                        let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request, format)?;
1382                        let resp = svc.#method_snake(ctx, req).await?;
1383                        Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream(s, format)))
1384                    })
1385                }
1386            });
1387        } else {
1388            // Unary
1389            call_unary_arms.push(quote! {
1390                #method_name => {
1391                    let svc = ::std::sync::Arc::clone(&self.inner);
1392                    Box::pin(async move {
1393                        let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request, format)?;
1394                        svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1395                    })
1396                }
1397            });
1398        }
1399    }
1400
1401    let server_doc = format!(
1402        "Monomorphic dispatcher for `{trait_name}`.\n\n\
1403         Unlike `.register(Router)` which type-erases each method into an \
1404         `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
1405         via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
1406         # Example\n\n\
1407         ```rust,ignore\n\
1408         use connectrpc::ConnectRpcService;\n\n\
1409         let server = {server_name}::new(MyImpl);\n\
1410         let service = ConnectRpcService::new(server);\n\
1411         // hand `service` to axum/hyper as a fallback_service\n\
1412         ```"
1413    );
1414    let server_doc_tokens = doc_attrs(&server_doc);
1415
1416    Ok(quote! {
1417        #server_doc_tokens
1418        pub struct #server_name<T> {
1419            inner: ::std::sync::Arc<T>,
1420        }
1421
1422        impl<T: #trait_name> #server_name<T> {
1423            /// Wrap a service implementation in a monomorphic dispatcher.
1424            pub fn new(service: T) -> Self {
1425                Self { inner: ::std::sync::Arc::new(service) }
1426            }
1427
1428            /// Wrap an already-`Arc`'d service implementation.
1429            pub fn from_arc(inner: ::std::sync::Arc<T>) -> Self {
1430                Self { inner }
1431            }
1432        }
1433
1434        impl<T> Clone for #server_name<T> {
1435            fn clone(&self) -> Self {
1436                Self { inner: ::std::sync::Arc::clone(&self.inner) }
1437            }
1438        }
1439
1440        impl<T: #trait_name> ::connectrpc::Dispatcher for #server_name<T> {
1441            #[inline]
1442            fn lookup(&self, path: &str) -> Option<::connectrpc::dispatcher::codegen::MethodDescriptor> {
1443                let method = path.strip_prefix(#path_prefix)?;
1444                match method {
1445                    #(#lookup_arms)*
1446                    _ => None,
1447                }
1448            }
1449
1450            fn call_unary(
1451                &self,
1452                path: &str,
1453                ctx: ::connectrpc::RequestContext,
1454                request: ::buffa::bytes::Bytes,
1455                format: ::connectrpc::CodecFormat,
1456            ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
1457                let Some(method) = path.strip_prefix(#path_prefix) else {
1458                    return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
1459                };
1460                // Suppress unused warnings when this service has no unary methods.
1461                let _ = (&ctx, &request, &format);
1462                match method {
1463                    #(#call_unary_arms)*
1464                    _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
1465                }
1466            }
1467
1468            fn call_server_streaming(
1469                &self,
1470                path: &str,
1471                ctx: ::connectrpc::RequestContext,
1472                request: ::buffa::bytes::Bytes,
1473                format: ::connectrpc::CodecFormat,
1474            ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
1475                let Some(method) = path.strip_prefix(#path_prefix) else {
1476                    return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
1477                };
1478                let _ = (&ctx, &request, &format);
1479                match method {
1480                    #(#call_ss_arms)*
1481                    _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
1482                }
1483            }
1484
1485            fn call_client_streaming(
1486                &self,
1487                path: &str,
1488                ctx: ::connectrpc::RequestContext,
1489                requests: ::connectrpc::dispatcher::codegen::RequestStream,
1490                format: ::connectrpc::CodecFormat,
1491            ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
1492                let Some(method) = path.strip_prefix(#path_prefix) else {
1493                    return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
1494                };
1495                let _ = (&ctx, &requests, &format);
1496                match method {
1497                    #(#call_cs_arms)*
1498                    _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
1499                }
1500            }
1501
1502            fn call_bidi_streaming(
1503                &self,
1504                path: &str,
1505                ctx: ::connectrpc::RequestContext,
1506                requests: ::connectrpc::dispatcher::codegen::RequestStream,
1507                format: ::connectrpc::CodecFormat,
1508            ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
1509                let Some(method) = path.strip_prefix(#path_prefix) else {
1510                    return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
1511                };
1512                let _ = (&ctx, &requests, &format);
1513                match method {
1514                    #(#call_bidi_arms)*
1515                    _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
1516                }
1517            }
1518        }
1519    })
1520}
1521
1522/// Generate documentation comment tokens.
1523fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
1524    let comment = if doc.is_empty() { default } else { doc };
1525    doc_attrs(comment)
1526}
1527
1528/// Generate a trait method for a service.
1529fn generate_trait_method(
1530    file: &FileDescriptorProto,
1531    service: &ServiceDescriptorProto,
1532    method: &MethodDescriptorProto,
1533    resolver: &TypeResolver<'_>,
1534    batch: &BatchState,
1535    package: &str,
1536) -> Result<TokenStream> {
1537    let method_name = method.name.as_deref().unwrap_or("");
1538    let method_snake = make_field_ident(&method_name.to_snake_case());
1539    let input_arg = owned_view_input_arg_type(
1540        resolver,
1541        batch,
1542        method.input_type.as_deref().unwrap_or(""),
1543        package,
1544    )?;
1545    let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
1546
1547    // Get method documentation
1548    let method_doc = get_method_comment(file, service, method).unwrap_or_default();
1549    let method_doc_tokens =
1550        generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
1551
1552    // Check for streaming
1553    let client_streaming = method.client_streaming.unwrap_or(false);
1554    let server_streaming = method.server_streaming.unwrap_or(false);
1555
1556    let borrow_doc = quote! {
1557        #[doc = ""]
1558        #[doc = " `'a` lets the response body borrow from `&self` (e.g. server-resident state)."]
1559    };
1560
1561    if server_streaming && !client_streaming {
1562        // Server streaming method
1563        Ok(quote! {
1564            #method_doc_tokens
1565            fn #method_snake(
1566                &self,
1567                ctx: ::connectrpc::RequestContext,
1568                request: #input_arg,
1569            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<#output_type>>> + Send;
1570        })
1571    } else if client_streaming && !server_streaming {
1572        // Client streaming method
1573        Ok(quote! {
1574            #method_doc_tokens
1575            #borrow_doc
1576            fn #method_snake<'a>(
1577                &'a self,
1578                ctx: ::connectrpc::RequestContext,
1579                requests: ::connectrpc::ServiceStream<#input_arg>,
1580            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
1581        })
1582    } else if client_streaming && server_streaming {
1583        // Bidi streaming method
1584        Ok(quote! {
1585            #method_doc_tokens
1586            fn #method_snake(
1587                &self,
1588                ctx: ::connectrpc::RequestContext,
1589                requests: ::connectrpc::ServiceStream<#input_arg>,
1590            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<#output_type>>> + Send;
1591        })
1592    } else {
1593        // Unary method
1594        Ok(quote! {
1595            #method_doc_tokens
1596            #borrow_doc
1597            fn #method_snake<'a>(
1598                &'a self,
1599                ctx: ::connectrpc::RequestContext,
1600                request: #input_arg,
1601            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
1602        })
1603    }
1604}
1605
1606/// Generate client method(s) for a service RPC.
1607///
1608/// Emits two methods per RPC:
1609///   - `<method_snake>(&self, ...)` — no-options convenience, delegates to `_with_options`
1610///   - `<method_snake>_with_options(&self, ..., options: CallOptions)` — explicit options
1611///
1612/// This gives callers an ergonomic default while still surfacing per-call
1613/// control. The library's `effective_options()` merges options over
1614/// ClientConfig defaults, so the no-options variant still picks up any
1615/// client-wide defaults the user configured.
1616fn generate_client_method(
1617    service_name_const: &Ident,
1618    full_service_name: &str,
1619    method: &MethodDescriptorProto,
1620    resolver: &TypeResolver<'_>,
1621    package: &str,
1622) -> Result<TokenStream> {
1623    let method_name = method.name.as_deref().unwrap_or("");
1624    let method_snake = make_field_ident(&method_name.to_snake_case());
1625    let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
1626    let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
1627    let output_view_type =
1628        resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
1629
1630    let client_streaming = method.client_streaming.unwrap_or(false);
1631    let server_streaming = method.server_streaming.unwrap_or(false);
1632
1633    let doc = format!(
1634        " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
1635    );
1636    let doc_opts = format!(
1637        " Call the {method_name} RPC with explicit per-call options. \
1638         Options override [`ClientConfig`](::connectrpc::client::ClientConfig) defaults."
1639    );
1640
1641    // Return type is protocol-specific. Compute once.
1642    let ret_ty: TokenStream;
1643    let call_body: TokenStream;
1644    let short_args: TokenStream; // args to the no-opts convenience method
1645    let opts_args: TokenStream; // args to the _with_options method
1646    let short_delegate_args: TokenStream; // how short delegates to opts
1647
1648    if client_streaming && !server_streaming {
1649        // Client-stream
1650        ret_ty = quote! {
1651            Result<
1652                ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
1653                ::connectrpc::ConnectError,
1654            >
1655        };
1656        call_body = quote! {
1657            ::connectrpc::client::call_client_stream(
1658                &self.transport, &self.config,
1659                #service_name_const, #method_name,
1660                requests, options,
1661            ).await
1662        };
1663        short_args = quote! { requests: impl IntoIterator<Item = #input_type> };
1664        opts_args = quote! { requests: impl IntoIterator<Item = #input_type>, options: ::connectrpc::client::CallOptions };
1665        short_delegate_args = quote! { requests, ::connectrpc::client::CallOptions::default() };
1666    } else if client_streaming && server_streaming {
1667        // Bidi
1668        ret_ty = quote! {
1669            Result<
1670                ::connectrpc::client::BidiStream<
1671                    T::ResponseBody, #input_type, #output_view_type<'static>
1672                >,
1673                ::connectrpc::ConnectError,
1674            >
1675        };
1676        call_body = quote! {
1677            ::connectrpc::client::call_bidi_stream(
1678                &self.transport, &self.config,
1679                #service_name_const, #method_name, options,
1680            ).await
1681        };
1682        short_args = quote! {};
1683        opts_args = quote! { options: ::connectrpc::client::CallOptions };
1684        short_delegate_args = quote! { ::connectrpc::client::CallOptions::default() };
1685    } else if server_streaming {
1686        // Server-stream
1687        ret_ty = quote! {
1688            Result<
1689                ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
1690                ::connectrpc::ConnectError,
1691            >
1692        };
1693        call_body = quote! {
1694            ::connectrpc::client::call_server_stream(
1695                &self.transport, &self.config,
1696                #service_name_const, #method_name,
1697                request, options,
1698            ).await
1699        };
1700        short_args = quote! { request: #input_type };
1701        opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
1702        short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
1703    } else {
1704        // Unary
1705        ret_ty = quote! {
1706            Result<
1707                ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
1708                ::connectrpc::ConnectError,
1709            >
1710        };
1711        call_body = quote! {
1712            ::connectrpc::client::call_unary(
1713                &self.transport, &self.config,
1714                #service_name_const, #method_name,
1715                request, options,
1716            ).await
1717        };
1718        short_args = quote! { request: #input_type };
1719        opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
1720        short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
1721    }
1722
1723    Ok(quote! {
1724        #[doc = #doc]
1725        pub async fn #method_snake(&self, #short_args) -> #ret_ty {
1726            self.#method_with_opts(#short_delegate_args).await
1727        }
1728
1729        #[doc = #doc_opts]
1730        pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
1731            #call_body
1732        }
1733    })
1734}
1735
1736/// Get the documentation comment for a service.
1737fn get_service_comment(
1738    file: &FileDescriptorProto,
1739    service: &ServiceDescriptorProto,
1740) -> Option<String> {
1741    // MessageField derefs to default when unset; default has empty location vec
1742    let source_info: &SourceCodeInfo = &file.source_code_info;
1743
1744    // Find service index
1745    let service_index = file.service.iter().position(|s| s.name == service.name)?;
1746
1747    // Path for service: [6, service_index]
1748    // 6 = service field number in FileDescriptorProto
1749    let target_path = vec![6, service_index as i32];
1750
1751    find_comment(source_info, &target_path)
1752}
1753
1754/// Get the documentation comment for a method.
1755fn get_method_comment(
1756    file: &FileDescriptorProto,
1757    service: &ServiceDescriptorProto,
1758    method: &MethodDescriptorProto,
1759) -> Option<String> {
1760    let source_info: &SourceCodeInfo = &file.source_code_info;
1761
1762    // Find service and method indices, matching on the parent service name
1763    // to avoid ambiguity when multiple services have methods with the same name.
1764    let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
1765        if s.name != service.name {
1766            return None;
1767        }
1768        s.method
1769            .iter()
1770            .position(|m| m.name == method.name)
1771            .map(|mi| (si, mi))
1772    })?;
1773
1774    // Path for method: [6, service_index, 2, method_index]
1775    // 6 = service field number in FileDescriptorProto
1776    // 2 = method field number in ServiceDescriptorProto
1777    let target_path = vec![6, service_index as i32, 2, method_index as i32];
1778
1779    find_comment(source_info, &target_path)
1780}
1781
1782/// Find a comment in source code info for the given path.
1783fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
1784    for location in &source_info.location {
1785        if location.path == target_path {
1786            let comment = location
1787                .leading_comments
1788                .as_ref()
1789                .or(location.trailing_comments.as_ref())?;
1790
1791            // Trim each line; blank lines are dropped (protoc's convention
1792            // uses a leading space we don't need here — `doc_attrs` adds
1793            // its own uniform leading space for prettyplease rendering).
1794            let cleaned: String = comment
1795                .lines()
1796                .map(|line| line.trim())
1797                .filter(|line| !line.is_empty())
1798                .collect::<Vec<_>>()
1799                .join("\n");
1800
1801            if !cleaned.is_empty() {
1802                return Some(cleaned);
1803            }
1804        }
1805    }
1806    None
1807}
1808
1809#[cfg(test)]
1810mod tests {
1811    use super::*;
1812    use buffa_codegen::generated::descriptor::DescriptorProto;
1813
1814    #[test]
1815    fn doc_attrs_prefixes_space_for_prettyplease() {
1816        // prettyplease emits `#[doc = "X"]` as `///X` verbatim. We prefix
1817        // each non-blank line with a space so the output is `/// X`.
1818        let ts = quote! {
1819            #[allow(dead_code)]
1820            mod m {}
1821        };
1822        let doc = doc_attrs("Hello.\n\nSecond paragraph.");
1823        let combined = quote! { #doc #ts };
1824        let file = syn::parse2::<syn::File>(combined).unwrap();
1825        let out = prettyplease::unparse(&file);
1826        // Each non-blank line should have a space after ///.
1827        assert!(out.contains("/// Hello."), "got: {out}");
1828        assert!(out.contains("/// Second paragraph."), "got: {out}");
1829        // Blank line becomes bare /// (paragraph break).
1830        assert!(out.contains("///\n"), "got: {out}");
1831        // Should NOT contain ///H (no space) or ///  H (double space).
1832        assert!(!out.contains("///Hello"), "got: {out}");
1833        assert!(!out.contains("///  Hello"), "got: {out}");
1834    }
1835
1836    /// Build a minimal proto file with one message type and one service method.
1837    /// The service method's input/output types are fully-qualified proto names
1838    /// (e.g. `.example.v1.PingReq` or `.google.protobuf.Empty`) so the resolver
1839    /// can look them up.
1840    fn minimal_file(
1841        package: Option<&str>,
1842        input_type: &str,
1843        output_type: &str,
1844        local_messages: &[&str],
1845    ) -> FileDescriptorProto {
1846        minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
1847    }
1848
1849    /// Like [`minimal_file`] but with a custom RPC method name, for testing
1850    /// keyword collisions and other name-derived behaviour.
1851    fn minimal_file_with_method(
1852        package: Option<&str>,
1853        method_name: &str,
1854        input_type: &str,
1855        output_type: &str,
1856        local_messages: &[&str],
1857    ) -> FileDescriptorProto {
1858        let method = MethodDescriptorProto {
1859            name: Some(method_name.into()),
1860            input_type: Some(input_type.into()),
1861            output_type: Some(output_type.into()),
1862            ..Default::default()
1863        };
1864        let service = ServiceDescriptorProto {
1865            name: Some("PingService".into()),
1866            method: vec![method],
1867            ..Default::default()
1868        };
1869        FileDescriptorProto {
1870            name: Some("ping.proto".into()),
1871            package: package.map(|p| p.into()),
1872            service: vec![service],
1873            message_type: local_messages
1874                .iter()
1875                .map(|name| DescriptorProto {
1876                    name: Some((*name).into()),
1877                    ..Default::default()
1878                })
1879                .collect(),
1880            ..Default::default()
1881        }
1882    }
1883
1884    /// Build a minimal proto file with one service holding the given method
1885    /// names, all typed `Empty` -> `Empty`. Used for collision tests where
1886    /// the method *names* are what's under test.
1887    fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
1888        let methods = method_names
1889            .iter()
1890            .map(|n| MethodDescriptorProto {
1891                name: Some((*n).into()),
1892                input_type: Some(format!(".{package}.Empty")),
1893                output_type: Some(format!(".{package}.Empty")),
1894                ..Default::default()
1895            })
1896            .collect();
1897        let service = ServiceDescriptorProto {
1898            name: Some("PingService".into()),
1899            method: methods,
1900            ..Default::default()
1901        };
1902        FileDescriptorProto {
1903            name: Some("ping.proto".into()),
1904            package: Some(package.into()),
1905            service: vec![service],
1906            message_type: vec![DescriptorProto {
1907                name: Some("Empty".into()),
1908                ..Default::default()
1909            }],
1910            ..Default::default()
1911        }
1912    }
1913
1914    /// Generate service code for `files[target_idx]`. All files are visible
1915    /// to the resolver (as transitive deps via `--include_imports`), but
1916    /// only the target is in `file_to_generate` — mirroring real protoc use.
1917    ///
1918    /// `extern_paths` is wired into `CodeGenConfig.extern_paths` (which
1919    /// feeds the resolver's type_map via `effective_extern_paths`).
1920    /// `require_extern` selects unified (`false`, super::-relative) vs
1921    /// split (`true`, absolute-only) mode.
1922    fn gen_service(
1923        files: &[FileDescriptorProto],
1924        target_idx: usize,
1925        extern_paths: &[(String, String)],
1926        require_extern: bool,
1927    ) -> Result<String> {
1928        let mut config = buffa_codegen::CodeGenConfig::default();
1929        config.extern_paths = extern_paths.to_vec();
1930        let target_name = files[target_idx]
1931            .name
1932            .clone()
1933            .into_iter()
1934            .collect::<Vec<_>>();
1935        let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
1936        let file = &files[target_idx];
1937        let service = &file.service[0];
1938        let batch = BatchState {
1939            colliding_aliases: collect_alias_collisions(files, &target_name),
1940            ..BatchState::default()
1941        };
1942        Ok(generate_service(file, service, &resolver, &batch)?.to_string())
1943    }
1944
1945    /// Assert that `formatted` (a Rust source string) contains no `use`
1946    /// items at the file root. Parses with `syn` rather than string-matching
1947    /// so doc comments, string literals, and indented `use` statements in
1948    /// nested modules cannot trigger false positives.
1949    fn assert_no_top_level_use(formatted: &str, label: &str) {
1950        let parsed: syn::File = syn::parse_str(formatted).expect("formatted code parses");
1951        let offenders: Vec<String> = parsed
1952            .items
1953            .iter()
1954            .filter_map(|item| match item {
1955                syn::Item::Use(u) => Some(quote!(#u).to_string()),
1956                _ => None,
1957            })
1958            .collect();
1959        assert!(
1960            offenders.is_empty(),
1961            "{label} contains top-level use statement(s): {offenders:?}\nFull source:\n{formatted}"
1962        );
1963    }
1964
1965    fn gen_file(
1966        files: &[FileDescriptorProto],
1967        target_idx: usize,
1968        extern_paths: &[(String, String)],
1969        require_extern: bool,
1970    ) -> Result<String> {
1971        let mut config = buffa_codegen::CodeGenConfig::default();
1972        config.extern_paths = extern_paths.to_vec();
1973        let target_name = files[target_idx]
1974            .name
1975            .clone()
1976            .into_iter()
1977            .collect::<Vec<_>>();
1978        let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
1979        let mut batch = BatchState {
1980            colliding_aliases: collect_alias_collisions(files, &target_name),
1981            ..BatchState::default()
1982        };
1983        Ok(generate_connect_services(&files[target_idx], &resolver, &mut batch)?.to_string())
1984    }
1985
1986    #[test]
1987    fn unary_response_body_captures_self_lifetime() {
1988        let file = minimal_file(
1989            Some("example.v1"),
1990            ".example.v1.PingReq",
1991            ".example.v1.PingResp",
1992            &["PingReq", "PingResp"],
1993        );
1994        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1995        assert!(code.contains("< 'a >"), "trait method missing 'a: {code}");
1996        assert!(code.contains("& 'a self"), "missing &'a self: {code}");
1997        assert!(
1998            code.contains("use < 'a , Self >"),
1999            "missing use<'a, Self> capture: {code}"
2000        );
2001        assert!(
2002            !code.contains("'static + use"),
2003            "'static bound on body should be dropped: {code}"
2004        );
2005    }
2006
2007    #[test]
2008    fn owned_view_aliases_emitted_for_input_and_output() {
2009        let file = minimal_file(
2010            Some("example.v1"),
2011            ".example.v1.PingReq",
2012            ".example.v1.PingResp",
2013            &["PingReq", "PingResp"],
2014        );
2015        let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2016        assert!(
2017            code.contains("pub type OwnedPingReqView = :: buffa :: view :: OwnedView"),
2018            "missing OwnedPingReqView alias: {code}"
2019        );
2020        assert!(
2021            code.contains("pub type OwnedPingRespView = :: buffa :: view :: OwnedView"),
2022            "missing OwnedPingRespView alias: {code}"
2023        );
2024        // Trait method uses the alias for the request param.
2025        assert!(
2026            code.contains("request : OwnedPingReqView ,"),
2027            "trait method should take request: OwnedPingReqView: {code}"
2028        );
2029    }
2030
2031    #[test]
2032    fn cross_package_input_collision_suppresses_alias_for_both_sides() {
2033        // Regression test for #75. A service file that defines its own
2034        // `MyMessage` and also uses an imported `.api.v1.foo.bar.MyMessage`
2035        // as an RPC input previously emitted `pub type OwnedMyMessageView`
2036        // twice (once for the local output, once for the cross-package
2037        // input), failing to compile with E0428. The fix detects the
2038        // colliding alias name and inlines the `OwnedView<…<'static>>`
2039        // form for both members of the colliding set.
2040        let v1 = FileDescriptorProto {
2041            name: Some("api/v1/foo/bar/foobar.proto".into()),
2042            package: Some("api.v1.foo.bar".into()),
2043            message_type: vec![DescriptorProto {
2044                name: Some("MyMessage".into()),
2045                ..Default::default()
2046            }],
2047            ..Default::default()
2048        };
2049        let v2 = minimal_file(
2050            Some("api.v2.foo.bar"),
2051            ".api.v1.foo.bar.MyMessage",
2052            ".api.v2.foo.bar.MyMessage",
2053            &["MyMessage"],
2054        );
2055        let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2056
2057        // Neither side gets an alias because both would land at the same
2058        // identifier in the same module.
2059        let alias_count = code.matches("pub type OwnedMyMessageView").count();
2060        assert_eq!(
2061            alias_count, 0,
2062            "expected zero OwnedMyMessageView aliases when both sides collide; got {alias_count}: {code}"
2063        );
2064
2065        // Both colliding sides reach the trait sig as the inlined
2066        // `OwnedView<…<'static>>` form.
2067        assert!(
2068            !code.contains("request : OwnedMyMessageView"),
2069            "colliding input must not reference the suppressed alias: {code}"
2070        );
2071        assert!(
2072            code.contains("request : :: buffa :: view :: OwnedView <"),
2073            "colliding input should be inlined as OwnedView<…<'static>>: {code}"
2074        );
2075    }
2076
2077    #[test]
2078    fn cross_package_input_without_collision_keeps_alias() {
2079        // The #75 fix only suppresses aliases when two distinct FQNs in
2080        // the same target package would produce the same alias name. A
2081        // cross-package input with a unique short name (e.g. WKT inputs
2082        // like `.google.protobuf.Empty`) keeps its `OwnedEmptyView`
2083        // alias — generated handler code that previously read
2084        // `request: OwnedEmptyView` keeps working.
2085        let wkt = FileDescriptorProto {
2086            name: Some("google/protobuf/empty.proto".into()),
2087            package: Some("google.protobuf".into()),
2088            message_type: vec![DescriptorProto {
2089                name: Some("Empty".into()),
2090                ..Default::default()
2091            }],
2092            ..Default::default()
2093        };
2094        let svc = minimal_file(
2095            Some("example.v1"),
2096            ".google.protobuf.Empty",
2097            ".example.v1.PingResp",
2098            &["PingResp"],
2099        );
2100        let code = gen_file(&[wkt, svc], 1, &[], false).unwrap();
2101        assert!(
2102            code.contains("pub type OwnedEmptyView = :: buffa :: view :: OwnedView"),
2103            "WKT cross-package input should keep its alias: {code}"
2104        );
2105        assert!(
2106            code.contains("request : OwnedEmptyView ,"),
2107            "trait method should still use OwnedEmptyView for non-colliding cross-package input: {code}"
2108        );
2109    }
2110
2111    #[test]
2112    fn collision_inlines_in_all_streaming_method_shapes() {
2113        // The #75 fix substitutes `#input_arg` at four interpolation
2114        // sites in `generate_trait_method` (server-streaming, client-
2115        // streaming, bidi, unary). This drives all four shapes through
2116        // a colliding cross-package input to catch any regression that
2117        // accidentally drops the substitution from one branch.
2118        let v1 = FileDescriptorProto {
2119            name: Some("api/v1/foo/bar/foobar.proto".into()),
2120            package: Some("api.v1.foo.bar".into()),
2121            message_type: vec![DescriptorProto {
2122                name: Some("MyMessage".into()),
2123                ..Default::default()
2124            }],
2125            ..Default::default()
2126        };
2127        let v2 = FileDescriptorProto {
2128            name: Some("api/v2/foo/bar/foobar.proto".into()),
2129            package: Some("api.v2.foo.bar".into()),
2130            message_type: vec![DescriptorProto {
2131                name: Some("MyMessage".into()),
2132                ..Default::default()
2133            }],
2134            service: vec![ServiceDescriptorProto {
2135                name: Some("FooBar".into()),
2136                method: vec![
2137                    MethodDescriptorProto {
2138                        name: Some("Unary".into()),
2139                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2140                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2141                        ..Default::default()
2142                    },
2143                    MethodDescriptorProto {
2144                        name: Some("ServerStream".into()),
2145                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2146                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2147                        server_streaming: Some(true),
2148                        ..Default::default()
2149                    },
2150                    MethodDescriptorProto {
2151                        name: Some("ClientStream".into()),
2152                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2153                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2154                        client_streaming: Some(true),
2155                        ..Default::default()
2156                    },
2157                    MethodDescriptorProto {
2158                        name: Some("Bidi".into()),
2159                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2160                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2161                        client_streaming: Some(true),
2162                        server_streaming: Some(true),
2163                        ..Default::default()
2164                    },
2165                ],
2166                ..Default::default()
2167            }],
2168            ..Default::default()
2169        };
2170        let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2171
2172        // None of the four method shapes reference the suppressed alias.
2173        assert!(
2174            !code.contains("OwnedMyMessageView"),
2175            "no method shape should reference the suppressed alias: {code}"
2176        );
2177
2178        // Each method shape uses the inlined OwnedView<…<'static>> form.
2179        // Unary + server-streaming take a single request param; client-
2180        // streaming + bidi take a ServiceStream<…>.
2181        assert!(
2182            code.matches("request : :: buffa :: view :: OwnedView <")
2183                .count()
2184                >= 2,
2185            "unary and server-streaming should both inline the request type: {code}"
2186        );
2187        assert!(
2188            code.matches(
2189                "requests : :: connectrpc :: ServiceStream < :: buffa :: view :: OwnedView <"
2190            )
2191            .count()
2192                >= 2,
2193            "client-streaming and bidi should both inline the streamed request type: {code}"
2194        );
2195    }
2196
2197    #[test]
2198    fn encodable_view_impls_emitted_per_output_type() {
2199        let file = minimal_file(
2200            Some("example.v1"),
2201            ".example.v1.PingReq",
2202            ".example.v1.PingResp",
2203            &["PingReq", "PingResp"],
2204        );
2205        let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2206        assert!(
2207            code.contains(
2208                ":: connectrpc :: Encodable < PingResp > for __buffa :: view :: PingRespView"
2209            ),
2210            "missing Encodable<PingResp> for PingRespView: {code}"
2211        );
2212        assert!(
2213            code.contains(
2214                ":: connectrpc :: Encodable < PingResp > for :: buffa :: view :: OwnedView"
2215            ),
2216            "missing Encodable<PingResp> for OwnedView<PingRespView>: {code}"
2217        );
2218        // Input type should NOT get an impl (only output types).
2219        assert!(!code.contains("Encodable < PingReq >"), "got: {code}");
2220    }
2221
2222    #[test]
2223    fn encodable_view_impls_skipped_for_extern_output() {
2224        // Output type resolves via the WKT extern_path → ::buffa_types::...
2225        // so the impl would be an orphan; verify it's skipped.
2226        let wkt = FileDescriptorProto {
2227            name: Some("google/protobuf/empty.proto".into()),
2228            package: Some("google.protobuf".into()),
2229            message_type: vec![DescriptorProto {
2230                name: Some("Empty".into()),
2231                ..Default::default()
2232            }],
2233            ..Default::default()
2234        };
2235        let file = minimal_file(
2236            Some("example.v1"),
2237            ".example.v1.PingReq",
2238            ".google.protobuf.Empty",
2239            &["PingReq"],
2240        );
2241        let code = gen_file(&[wkt, file], 1, &[], false).unwrap();
2242        // The impl bodies call encode_view_body; the trait method's
2243        // `impl Encodable<M>` RPITIT bound doesn't.
2244        assert!(
2245            !code.contains("encode_view_body"),
2246            "extern output type must not get Encodable impl: {code}"
2247        );
2248    }
2249
2250    #[test]
2251    fn encodable_view_impls_deduped_across_files() {
2252        // Two service files in different packages both return
2253        // `.common.v1.Reply`. The stitcher mounts both files into one
2254        // module tree, so the Encodable<Reply> impls must be emitted
2255        // exactly once across the batch (else E0119).
2256        let common = FileDescriptorProto {
2257            name: Some("common.proto".into()),
2258            package: Some("common.v1".into()),
2259            message_type: vec![DescriptorProto {
2260                name: Some("Reply".into()),
2261                ..Default::default()
2262            }],
2263            ..Default::default()
2264        };
2265        let svc = |name: &str, pkg: &str| FileDescriptorProto {
2266            name: Some(name.into()),
2267            package: Some(pkg.into()),
2268            message_type: vec![DescriptorProto {
2269                name: Some("Req".into()),
2270                ..Default::default()
2271            }],
2272            service: vec![ServiceDescriptorProto {
2273                name: Some("S".into()),
2274                method: vec![MethodDescriptorProto {
2275                    name: Some("Call".into()),
2276                    input_type: Some(format!(".{pkg}.Req")),
2277                    output_type: Some(".common.v1.Reply".into()),
2278                    ..Default::default()
2279                }],
2280                ..Default::default()
2281            }],
2282            ..Default::default()
2283        };
2284        let files = vec![common, svc("a.proto", "a.v1"), svc("b.proto", "b.v1")];
2285
2286        let generated = generate_files(
2287            &files,
2288            &["a.proto".into(), "b.proto".into()],
2289            &Options::default(),
2290        )
2291        .unwrap();
2292
2293        // Each service-declaring proto produces exactly one Companion file
2294        // named `<stem>.__connect.rs`, wired into its package stitcher.
2295        let companions: Vec<_> = generated
2296            .iter()
2297            .filter(|f| f.kind == GeneratedFileKind::Companion)
2298            .collect();
2299        let mut companion_names: Vec<&str> = companions.iter().map(|f| f.name.as_str()).collect();
2300        companion_names.sort_unstable();
2301        assert_eq!(companion_names, ["a.__connect.rs", "b.__connect.rs"]);
2302        for c in &companions {
2303            let stitcher = generated
2304                .iter()
2305                .find(|g| g.kind == GeneratedFileKind::PackageMod && g.package == c.package)
2306                .expect("each companion's package must have a stitcher");
2307            assert!(
2308                stitcher
2309                    .content
2310                    .contains(&format!("include!(\"{}\")", c.name)),
2311                "stitcher for {} must include companion {}",
2312                c.package,
2313                c.name
2314            );
2315        }
2316
2317        let combined: String = companions.iter().map(|f| f.content.as_str()).collect();
2318
2319        let view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor super::super::common::v1::__buffa::view::ReplyView<'_>";
2320        let owned_view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor ::buffa::view::OwnedView<";
2321        assert_eq!(
2322            combined.matches(view_impl).count(),
2323            1,
2324            "Encodable<Reply> for ReplyView<'_> must appear once: {combined}"
2325        );
2326        assert_eq!(
2327            combined.matches(owned_view_impl).count(),
2328            1,
2329            "Encodable<Reply> for OwnedView<ReplyView> must appear once: {combined}"
2330        );
2331    }
2332
2333    /// Two service-declaring protos in the same package, plus one in a
2334    /// second package, with a shared dependency proto. Used by the
2335    /// `file_per_package` tests to exercise cross-file inlining and
2336    /// per-package grouping together.
2337    fn file_per_package_fixture() -> Vec<FileDescriptorProto> {
2338        let common = FileDescriptorProto {
2339            name: Some("common.proto".into()),
2340            package: Some("common.v1".into()),
2341            message_type: vec![DescriptorProto {
2342                name: Some("Reply".into()),
2343                ..Default::default()
2344            }],
2345            ..Default::default()
2346        };
2347        // Each service file declares its own request message — proto packages
2348        // can't have duplicate FQNs, so two same-package files with the same
2349        // message name would be an invalid descriptor set (and inlining both
2350        // into one `<dotted.pkg>.rs` under file_per_package would E0428).
2351        let svc = |proto_name: &str, pkg: &str, svc_name: &str, req: &str| FileDescriptorProto {
2352            name: Some(proto_name.into()),
2353            package: Some(pkg.into()),
2354            message_type: vec![DescriptorProto {
2355                name: Some(req.into()),
2356                ..Default::default()
2357            }],
2358            service: vec![ServiceDescriptorProto {
2359                name: Some(svc_name.into()),
2360                method: vec![MethodDescriptorProto {
2361                    name: Some("Call".into()),
2362                    input_type: Some(format!(".{pkg}.{req}")),
2363                    output_type: Some(".common.v1.Reply".into()),
2364                    ..Default::default()
2365                }],
2366                ..Default::default()
2367            }],
2368            ..Default::default()
2369        };
2370        vec![
2371            common,
2372            svc("a/x.proto", "a.v1", "XService", "XReq"),
2373            svc("a/y.proto", "a.v1", "YService", "YReq"),
2374            svc("b/z.proto", "b.v1", "ZService", "ZReq"),
2375        ]
2376    }
2377
2378    #[test]
2379    fn generate_files_file_per_package_inlines_companions() {
2380        let files = file_per_package_fixture();
2381        let mut options = Options::default();
2382        options.buffa.file_per_package = true;
2383
2384        let generated = generate_files(
2385            &files,
2386            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2387            &options,
2388        )
2389        .unwrap();
2390
2391        // No Companion files survive — service stubs are inlined.
2392        assert!(
2393            !generated
2394                .iter()
2395                .any(|f| f.kind == GeneratedFileKind::Companion),
2396            "file_per_package must not emit sibling Companion files"
2397        );
2398        assert!(
2399            !generated.iter().any(|f| f.name.ends_with(".__connect.rs")),
2400            "file_per_package must not emit `<stem>.__connect.rs` files"
2401        );
2402
2403        // Each service-declaring package's PackageMod inlines its services.
2404        let a = generated
2405            .iter()
2406            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "a.v1")
2407            .expect("a.v1 PackageMod must exist");
2408        assert!(
2409            a.content.contains("pub trait XService"),
2410            "a.v1 missing XService"
2411        );
2412        assert!(
2413            a.content.contains("pub trait YService"),
2414            "a.v1 missing YService"
2415        );
2416        assert!(
2417            !a.content.contains("pub trait ZService"),
2418            "a.v1 must not inline ZService"
2419        );
2420        assert!(
2421            !a.content.contains("__connect.rs"),
2422            "a.v1 PackageMod must not include! a connect file: {}",
2423            a.content
2424        );
2425
2426        let b = generated
2427            .iter()
2428            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "b.v1")
2429            .expect("b.v1 PackageMod must exist");
2430        assert!(
2431            b.content.contains("pub trait ZService"),
2432            "b.v1 missing ZService"
2433        );
2434        assert!(
2435            !b.content.contains("pub trait XService"),
2436            "b.v1 must not inline XService"
2437        );
2438
2439        // No PackageMod is emitted for the dependency-only package
2440        // `common.v1` — it is not in `file_to_generate`.
2441        let pkg_mods = generated
2442            .iter()
2443            .filter(|f| f.kind == GeneratedFileKind::PackageMod)
2444            .count();
2445        assert_eq!(
2446            pkg_mods, 2,
2447            "expected exactly two PackageMods: {generated:#?}"
2448        );
2449
2450        // The cross-file Encodable<Reply> dedup must hold under
2451        // file_per_package exactly as it does under the per-proto split:
2452        // one impl pair across the whole batch (else E0119 at consumer
2453        // compile time). All three services return `.common.v1.Reply`.
2454        let combined: String = generated.iter().map(|f| f.content.as_str()).collect();
2455        assert_eq!(
2456            combined
2457                .matches("impl ::connectrpc::Encodable<super::super::common::v1::Reply>")
2458                .count(),
2459            2,
2460            "Encodable<Reply> impls must be deduplicated across packages \
2461             (1 for ReplyView, 1 for OwnedView<ReplyView>): {combined}"
2462        );
2463    }
2464
2465    #[test]
2466    fn generate_services_file_per_package_emits_one_file_per_package() {
2467        let files = file_per_package_fixture();
2468        let mut options = Options::default();
2469        options.buffa.file_per_package = true;
2470        options
2471            .buffa
2472            .extern_paths
2473            .push((".".into(), "crate::proto".into()));
2474
2475        let generated = generate_services(
2476            &files,
2477            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2478            &options,
2479        )
2480        .unwrap();
2481
2482        // Output is exactly one PackageMod per service-declaring package
2483        // with all stubs inlined; no companions, no `<pkg>.mod.rs` stitchers.
2484        assert_eq!(
2485            generated.len(),
2486            2,
2487            "expected exactly two output files: {generated:#?}"
2488        );
2489        assert!(
2490            generated
2491                .iter()
2492                .all(|f| f.kind == GeneratedFileKind::PackageMod),
2493            "all output files must be PackageMod"
2494        );
2495        assert!(
2496            !generated.iter().any(|f| f.name.ends_with(".mod.rs")),
2497            "file_per_package must not emit a separate stitcher"
2498        );
2499        assert!(
2500            !generated.iter().any(|f| f.content.contains("include!")),
2501            "file_per_package output must not include! sibling files"
2502        );
2503
2504        let mut names: Vec<&str> = generated.iter().map(|f| f.name.as_str()).collect();
2505        names.sort_unstable();
2506        assert_eq!(
2507            names,
2508            ["a.v1.rs", "b.v1.rs"],
2509            "filenames must be `<dotted.pkg>.rs` to match buffa's file_per_package convention"
2510        );
2511
2512        let a = generated.iter().find(|f| f.package == "a.v1").unwrap();
2513        assert!(a.content.contains("pub trait XService"));
2514        assert!(a.content.contains("pub trait YService"));
2515        let b = generated.iter().find(|f| f.package == "b.v1").unwrap();
2516        assert!(b.content.contains("pub trait ZService"));
2517        assert!(!b.content.contains("pub trait XService"));
2518    }
2519
2520    #[test]
2521    fn generate_services_file_per_package_default_layout_unchanged() {
2522        // Sanity: when the option is off, the existing per-proto + stitcher
2523        // layout is preserved (regression guard for the new branch).
2524        let files = file_per_package_fixture();
2525        let mut options = Options::default();
2526        options
2527            .buffa
2528            .extern_paths
2529            .push((".".into(), "crate::proto".into()));
2530
2531        let generated = generate_services(
2532            &files,
2533            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2534            &options,
2535        )
2536        .unwrap();
2537
2538        let mut companions: Vec<&str> = generated
2539            .iter()
2540            .filter(|f| f.kind == GeneratedFileKind::Companion)
2541            .map(|f| f.name.as_str())
2542            .collect();
2543        companions.sort_unstable();
2544        assert_eq!(
2545            companions,
2546            ["a.x.__connect.rs", "a.y.__connect.rs", "b.z.__connect.rs"],
2547            "default layout emits one companion per proto"
2548        );
2549        let mut stitchers: Vec<&str> = generated
2550            .iter()
2551            .filter(|f| f.kind == GeneratedFileKind::PackageMod)
2552            .map(|f| f.name.as_str())
2553            .collect();
2554        stitchers.sort_unstable();
2555        assert_eq!(
2556            stitchers,
2557            ["a.v1.mod.rs", "b.v1.mod.rs"],
2558            "default layout emits one stitcher per package"
2559        );
2560        // Each stitcher include!s its package's companions.
2561        let a_stitcher = generated.iter().find(|f| f.name == "a.v1.mod.rs").unwrap();
2562        assert!(
2563            a_stitcher
2564                .content
2565                .contains(r#"include!("a.x.__connect.rs");"#)
2566        );
2567        assert!(
2568            a_stitcher
2569                .content
2570                .contains(r#"include!("a.y.__connect.rs");"#)
2571        );
2572    }
2573
2574    #[test]
2575    fn service_name_with_package() {
2576        let file = minimal_file(
2577            Some("example.v1"),
2578            ".example.v1.PingReq",
2579            ".example.v1.PingResp",
2580            &["PingReq", "PingResp"],
2581        );
2582        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2583        assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
2584    }
2585
2586    #[test]
2587    fn service_name_without_package() {
2588        // Empty package must produce "PingService", not ".PingService".
2589        let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
2590        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2591        assert!(code.contains("\"PingService\""), "got: {code}");
2592        assert!(
2593            !code.contains("\".PingService\""),
2594            "must not have leading dot: {code}"
2595        );
2596    }
2597
2598    #[test]
2599    fn same_package_types_use_bare_names() {
2600        let file = minimal_file(
2601            Some("example.v1"),
2602            ".example.v1.PingReq",
2603            ".example.v1.PingResp",
2604            &["PingReq", "PingResp"],
2605        );
2606        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2607        // Same-package types resolve to bare identifiers.
2608        assert!(code.contains("PingReq"), "input type missing: {code}");
2609        assert!(code.contains("PingResp"), "output type missing: {code}");
2610        // No super:: prefix for same-package types.
2611        assert!(
2612            !code.contains("super :: PingReq"),
2613            "unexpected super: {code}"
2614        );
2615    }
2616
2617    #[test]
2618    fn cross_package_types_use_relative_paths() {
2619        // Service in example.v1 references types from common.v1.
2620        // Must emit a super::-relative path matching buffa's module
2621        // layout, not bare `Shared` (which would fail to compile).
2622        let common = FileDescriptorProto {
2623            name: Some("common.proto".into()),
2624            package: Some("common.v1".into()),
2625            message_type: vec![DescriptorProto {
2626                name: Some("Shared".into()),
2627                ..Default::default()
2628            }],
2629            ..Default::default()
2630        };
2631        let svc = minimal_file(
2632            Some("example.v1"),
2633            ".common.v1.Shared",
2634            ".example.v1.Out",
2635            &["Out"],
2636        );
2637        let code = gen_service(&[common, svc], 1, &[], false).unwrap();
2638
2639        // example.v1 -> super::super -> common::v1::Shared
2640        // (token stream stringifies `::` with spaces, so match loosely)
2641        assert!(
2642            code.contains("super :: super :: common :: v1 :: Shared"),
2643            "cross-package path not emitted: {code}"
2644        );
2645        assert!(
2646            code.contains("super :: super :: common :: v1 :: __buffa :: view :: SharedView"),
2647            "cross-package view path not emitted: {code}"
2648        );
2649    }
2650
2651    #[test]
2652    fn nested_message_view_type_mirrors_owned_module_nesting() {
2653        // Service in example.v1 references Outer.Inner (nested under Outer).
2654        // buffa lays out the view as __buffa::view::outer::InnerView, mirroring
2655        // the owned outer::Inner layout. rust_view_type must insert the
2656        // sentinel at the package boundary, not at the type boundary.
2657        let file = FileDescriptorProto {
2658            name: Some("nested.proto".into()),
2659            package: Some("example.v1".into()),
2660            message_type: vec![
2661                DescriptorProto {
2662                    name: Some("Outer".into()),
2663                    nested_type: vec![DescriptorProto {
2664                        name: Some("Inner".into()),
2665                        ..Default::default()
2666                    }],
2667                    ..Default::default()
2668                },
2669                DescriptorProto {
2670                    name: Some("Out".into()),
2671                    ..Default::default()
2672                },
2673            ],
2674            service: vec![ServiceDescriptorProto {
2675                name: Some("NestedService".into()),
2676                method: vec![MethodDescriptorProto {
2677                    name: Some("Ping".into()),
2678                    input_type: Some(".example.v1.Outer.Inner".into()),
2679                    output_type: Some(".example.v1.Out".into()),
2680                    ..Default::default()
2681                }],
2682                ..Default::default()
2683            }],
2684            ..Default::default()
2685        };
2686        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2687
2688        assert!(
2689            code.contains("__buffa :: view :: outer :: InnerView"),
2690            "nested view path not emitted: {code}"
2691        );
2692        assert!(
2693            code.contains("outer :: Inner"),
2694            "nested owned path not emitted: {code}"
2695        );
2696    }
2697
2698    #[test]
2699    fn wkt_types_use_buffa_types_extern_path() {
2700        // Service referencing google.protobuf.Empty as an input/output
2701        // type. WKT auto-injection maps it to ::buffa_types::..., same
2702        // path buffa-codegen emits for WKT message fields.
2703        let wkt = FileDescriptorProto {
2704            name: Some("google/protobuf/empty.proto".into()),
2705            package: Some("google.protobuf".into()),
2706            message_type: vec![DescriptorProto {
2707                name: Some("Empty".into()),
2708                ..Default::default()
2709            }],
2710            ..Default::default()
2711        };
2712        let svc = minimal_file(
2713            Some("example.v1"),
2714            ".google.protobuf.Empty",
2715            ".example.v1.Out",
2716            &["Out"],
2717        );
2718        let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
2719
2720        assert!(
2721            code.contains(":: buffa_types :: google :: protobuf :: Empty"),
2722            "WKT extern path not emitted: {code}"
2723        );
2724    }
2725
2726    #[test]
2727    fn extern_catchall_uses_absolute_paths() {
2728        let file = minimal_file(
2729            Some("example.v1"),
2730            ".example.v1.PingReq",
2731            ".example.v1.PingResp",
2732            &["PingReq", "PingResp"],
2733        );
2734        let extern_paths = [(".".into(), "crate::proto".into())];
2735        let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
2736        assert!(
2737            code.contains("crate :: proto :: example :: v1 :: PingReq"),
2738            "owned type path missing: {code}"
2739        );
2740        assert!(
2741            code.contains("crate :: proto :: example :: v1 :: __buffa :: view :: PingReqView"),
2742            "view type path missing: {code}"
2743        );
2744    }
2745
2746    #[test]
2747    fn extern_catchall_with_wkt_longest_wins() {
2748        // Auto-injected `.google.protobuf` mapping is more specific than
2749        // the `.` catch-all, so WKTs still route to ::buffa_types.
2750        let wkt = FileDescriptorProto {
2751            name: Some("google/protobuf/empty.proto".into()),
2752            package: Some("google.protobuf".into()),
2753            message_type: vec![DescriptorProto {
2754                name: Some("Empty".into()),
2755                ..Default::default()
2756            }],
2757            ..Default::default()
2758        };
2759        let svc = minimal_file(
2760            Some("example.v1"),
2761            ".google.protobuf.Empty",
2762            ".example.v1.Out",
2763            &["Out"],
2764        );
2765        let extern_paths = [(".".into(), "crate::proto".into())];
2766        let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
2767        assert!(
2768            code.contains(":: buffa_types :: google :: protobuf :: Empty"),
2769            "WKT mapping lost to catch-all: {code}"
2770        );
2771        assert!(
2772            code.contains("crate :: proto :: example :: v1 :: Out"),
2773            "local type not routed through catch-all: {code}"
2774        );
2775    }
2776
2777    #[test]
2778    fn missing_extern_path_errors() {
2779        let file = minimal_file(
2780            Some("example.v1"),
2781            ".example.v1.PingReq",
2782            ".example.v1.PingResp",
2783            &["PingReq", "PingResp"],
2784        );
2785        let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
2786        let msg = err.to_string();
2787        assert!(
2788            msg.contains("extern_path"),
2789            "error message lacks hint: {msg}"
2790        );
2791    }
2792
2793    #[test]
2794    fn keyword_package_escaped() {
2795        // `google.type` -> `google::r#type` via idents::rust_path_to_tokens.
2796        let file = minimal_file(
2797            Some("google.type"),
2798            ".google.type.LatLng",
2799            ".google.type.LatLng",
2800            &["LatLng"],
2801        );
2802        let extern_paths = [(".".into(), "crate::proto".into())];
2803        let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
2804        assert!(
2805            code.contains("crate :: proto :: google :: r#type :: LatLng"),
2806            "keyword segment not escaped: {code}"
2807        );
2808    }
2809
2810    #[test]
2811    fn keyword_method_escaped() {
2812        // `rpc Move(...)` -> snake_case `move` is a Rust keyword; emit `r#move`
2813        // via idents::make_field_ident. Regression for issue #23.
2814        let file = minimal_file_with_method(
2815            Some("example.v1"),
2816            "Move",
2817            ".example.v1.Empty",
2818            ".example.v1.Empty",
2819            &["Empty"],
2820        );
2821        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2822        assert!(
2823            code.contains("fn r#move"),
2824            "keyword method not escaped: {code}"
2825        );
2826        assert!(
2827            code.contains("move_with_options"),
2828            "suffixed variant should not need escaping: {code}"
2829        );
2830        // Doc example should also use the escaped form so the snippet is valid.
2831        assert!(code.contains("client.r#move(request)"));
2832        syn::parse_str::<syn::File>(&code).expect("generated code parses");
2833    }
2834
2835    #[test]
2836    fn path_keyword_method_suffixed() {
2837        // `self`/`super`/`Self`/`crate` cannot be raw identifiers; they are
2838        // suffixed with `_` instead (matching prost convention).
2839        let file = minimal_file_with_method(
2840            Some("example.v1"),
2841            "Self",
2842            ".example.v1.Empty",
2843            ".example.v1.Empty",
2844            &["Empty"],
2845        );
2846        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2847        assert!(
2848            code.contains("fn self_"),
2849            "path-keyword method not suffixed: {code}"
2850        );
2851        // The `_with_options` variant uses the unsuffixed snake name; the
2852        // suffix already de-keywords it, so we get `self_with_options`
2853        // (not `self__with_options`).
2854        assert!(code.contains("self_with_options"));
2855        syn::parse_str::<syn::File>(&code).expect("generated code parses");
2856    }
2857
2858    #[test]
2859    fn service_name_keyword_suffixed() {
2860        // `service Self {}` is accepted by protoc but `Self` is a Rust keyword
2861        // that cannot be a raw ident; the bare trait name is suffixed `Self_`
2862        // while the derived `SelfExt`/`SelfClient`/`SelfServer` are already safe.
2863        let mut file = minimal_file(
2864            Some("example.v1"),
2865            ".example.v1.Empty",
2866            ".example.v1.Empty",
2867            &["Empty"],
2868        );
2869        file.service[0].name = Some("Self".into());
2870        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2871        assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
2872        assert!(code.contains("trait SelfExt"));
2873        assert!(code.contains("struct SelfClient"));
2874        assert!(code.contains("struct SelfServer"));
2875        syn::parse_str::<syn::File>(&code).expect("generated code parses");
2876    }
2877
2878    #[test]
2879    fn method_snake_collision_errors() {
2880        // protoc accepts `GetFoo` and `get_foo` in the same service; both
2881        // snake-case to `get_foo`, which would emit duplicate Rust methods.
2882        let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
2883        let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
2884        let msg = err.to_string();
2885        assert!(msg.contains("PingService"), "missing service name: {msg}");
2886        assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
2887        assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
2888        assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
2889    }
2890
2891    #[test]
2892    fn method_with_options_collision_errors() {
2893        // `Ping` generates client method `ping_with_options`; a proto method
2894        // `PingWithOptions` would generate the same base name.
2895        let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
2896        let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
2897        let msg = err.to_string();
2898        assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
2899        assert!(
2900            msg.contains("\"PingWithOptions\""),
2901            "missing second method: {msg}"
2902        );
2903        assert!(
2904            msg.contains("`ping_with_options`"),
2905            "missing rust ident: {msg}"
2906        );
2907    }
2908
2909    #[test]
2910    fn distinct_methods_do_not_collide() {
2911        let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
2912        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2913        syn::parse_str::<syn::File>(&code).expect("generated code parses");
2914    }
2915
2916    #[test]
2917    fn options_default_buffa_config() {
2918        let cfg = Options::default().to_buffa_config();
2919        assert!(cfg.generate_json, "connectrpc enables JSON by default");
2920        assert!(cfg.generate_views);
2921        assert!(cfg.emit_register_fn);
2922        assert!(!cfg.strict_utf8_mapping);
2923    }
2924
2925    #[test]
2926    fn options_buffa_passthrough_forces_views() {
2927        let mut opts = Options::default();
2928        opts.buffa.emit_register_fn = false;
2929        opts.buffa.generate_views = false;
2930        let cfg = opts.to_buffa_config();
2931        assert!(!cfg.emit_register_fn);
2932        assert!(cfg.generate_views, "generate_views must be forced on");
2933    }
2934
2935    #[test]
2936    fn generate_files_emit_register_fn_false_suppresses_register_types() {
2937        // Build a file with a single message so buffa would normally emit
2938        // `pub fn register_types(&mut TypeRegistry)` aggregating it.
2939        let file = FileDescriptorProto {
2940            name: Some("ping.proto".into()),
2941            package: Some("example.v1".into()),
2942            message_type: vec![DescriptorProto {
2943                name: Some("PingReq".into()),
2944                ..Default::default()
2945            }],
2946            ..Default::default()
2947        };
2948
2949        // `register_types` is emitted into the per-package stitcher, so
2950        // locate the PackageMod output and check that one.
2951        let stitcher = |files: &[GeneratedFile]| {
2952            files
2953                .iter()
2954                .find(|f| f.kind == GeneratedFileKind::PackageMod)
2955                .expect("PackageMod file emitted")
2956                .content
2957                .clone()
2958        };
2959
2960        let with_fn = generate_files(
2961            std::slice::from_ref(&file),
2962            &["ping.proto".into()],
2963            &Options::default(),
2964        )
2965        .unwrap();
2966        let mod_rs = stitcher(&with_fn);
2967        assert!(
2968            mod_rs.contains("fn register_types"),
2969            "expected register_types in default output: {mod_rs}"
2970        );
2971
2972        let mut opts = Options::default();
2973        opts.buffa.emit_register_fn = false;
2974        let without_fn =
2975            generate_files(std::slice::from_ref(&file), &["ping.proto".into()], &opts).unwrap();
2976        let mod_rs = stitcher(&without_fn);
2977        assert!(
2978            !mod_rs.contains("fn register_types"),
2979            "register_types should be suppressed: {mod_rs}"
2980        );
2981    }
2982
2983    #[test]
2984    fn plugin_no_register_fn_parses() {
2985        let request = CodeGeneratorRequest {
2986            parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
2987            file_to_generate: vec![],
2988            proto_file: vec![],
2989            ..Default::default()
2990        };
2991        // Plugin path emits services only, so we can't observe the buffa
2992        // config directly — just make sure the option parses without error.
2993        generate(&request).expect("no_register_fn should be a recognized plugin option");
2994    }
2995
2996    #[test]
2997    fn plugin_file_per_package_collapses_output() {
2998        // End-to-end through the protoc entry point: one `<dotted.pkg>.rs`
2999        // per package, no `<stem>.__connect.rs`, no `<pkg>.mod.rs`.
3000        let request = CodeGeneratorRequest {
3001            parameter: Some("buffa_module=crate::proto,file_per_package".into()),
3002            file_to_generate: vec!["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3003            proto_file: file_per_package_fixture(),
3004            ..Default::default()
3005        };
3006        let response = generate(&request).expect("file_per_package should parse and generate");
3007        let mut names: Vec<&str> = response
3008            .file
3009            .iter()
3010            .filter_map(|f| f.name.as_deref())
3011            .collect();
3012        names.sort_unstable();
3013        assert_eq!(
3014            names,
3015            ["a.v1.rs", "b.v1.rs"],
3016            "expected one file per package: {names:?}"
3017        );
3018        for f in &response.file {
3019            let content = f.content.as_deref().unwrap_or_default();
3020            assert!(
3021                !content.contains("include!"),
3022                "file_per_package output must be self-contained: {content}"
3023            );
3024        }
3025    }
3026
3027    #[test]
3028    fn no_top_level_use_statements_in_generated_code() {
3029        // When multiple service files are `include!`d into the same module,
3030        // top-level `use` statements cause E0252 (duplicate imports). Verify
3031        // the generated code uses fully qualified paths instead.
3032        let file = minimal_file(
3033            Some("example.v1"),
3034            ".example.v1.PingReq",
3035            ".example.v1.PingResp",
3036            &["PingReq", "PingResp"],
3037        );
3038        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3039        let formatted = format_token_stream(&code.parse::<TokenStream>().unwrap()).unwrap();
3040        assert_no_top_level_use(&formatted, "generated code");
3041    }
3042
3043    #[test]
3044    fn multi_service_include_no_e0252() {
3045        // Simulate `buffa-packaging` including two service files into one
3046        // module. Both files must parse together without duplicate imports.
3047        let file_a = {
3048            let method = MethodDescriptorProto {
3049                name: Some("Ping".into()),
3050                input_type: Some(".svc.v1.PingReq".into()),
3051                output_type: Some(".svc.v1.PingResp".into()),
3052                ..Default::default()
3053            };
3054            let service = ServiceDescriptorProto {
3055                name: Some("Alpha".into()),
3056                method: vec![method],
3057                ..Default::default()
3058            };
3059            FileDescriptorProto {
3060                name: Some("alpha.proto".into()),
3061                package: Some("svc.v1".into()),
3062                service: vec![service],
3063                message_type: vec![
3064                    DescriptorProto {
3065                        name: Some("PingReq".into()),
3066                        ..Default::default()
3067                    },
3068                    DescriptorProto {
3069                        name: Some("PingResp".into()),
3070                        ..Default::default()
3071                    },
3072                ],
3073                ..Default::default()
3074            }
3075        };
3076        let file_b = {
3077            let method = MethodDescriptorProto {
3078                name: Some("Pong".into()),
3079                input_type: Some(".svc.v1.PongReq".into()),
3080                output_type: Some(".svc.v1.PongResp".into()),
3081                ..Default::default()
3082            };
3083            let service = ServiceDescriptorProto {
3084                name: Some("Beta".into()),
3085                method: vec![method],
3086                ..Default::default()
3087            };
3088            FileDescriptorProto {
3089                name: Some("beta.proto".into()),
3090                package: Some("svc.v1".into()),
3091                service: vec![service],
3092                message_type: vec![
3093                    DescriptorProto {
3094                        name: Some("PongReq".into()),
3095                        ..Default::default()
3096                    },
3097                    DescriptorProto {
3098                        name: Some("PongResp".into()),
3099                        ..Default::default()
3100                    },
3101                ],
3102                ..Default::default()
3103            }
3104        };
3105
3106        let files = vec![file_a, file_b];
3107        let config = buffa_codegen::CodeGenConfig::default();
3108        let targets = vec!["alpha.proto".to_string(), "beta.proto".to_string()];
3109        let resolver = TypeResolver::new(&files, &targets, &config, false);
3110
3111        let mut batch = BatchState {
3112            colliding_aliases: collect_alias_collisions(&files, &targets),
3113            ..BatchState::default()
3114        };
3115        let code_a = generate_connect_services(&files[0], &resolver, &mut batch).unwrap();
3116        let code_b = generate_connect_services(&files[1], &resolver, &mut batch).unwrap();
3117
3118        let formatted_a = format_token_stream(&code_a).unwrap();
3119        let formatted_b = format_token_stream(&code_b).unwrap();
3120
3121        // Each file independently must parse.
3122        syn::parse_str::<syn::File>(&formatted_a).expect("service A should parse independently");
3123        syn::parse_str::<syn::File>(&formatted_b).expect("service B should parse independently");
3124
3125        // Both files combined into one module must also parse (the E0252 scenario).
3126        let combined = format!("{formatted_a}\n{formatted_b}");
3127        syn::parse_str::<syn::File>(&combined)
3128            .expect("combined services should parse without E0252");
3129
3130        // No top-level `use` in either file.
3131        assert_no_top_level_use(&formatted_a, "service A");
3132        assert_no_top_level_use(&formatted_b, "service B");
3133    }
3134}