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`,\n\
1018         [`MaybeBorrowed`](::connectrpc::MaybeBorrowed), or\n\
1019         [`PreEncoded`](::connectrpc::PreEncoded) for handlers that encode a\n\
1020         non-`'static` view internally and pass the bytes across the handler\n\
1021         boundary. View bodies are not emitted for output types mapped via\n\
1022         `extern_path` (the impl would be an orphan); return owned for\n\
1023         WKT/extern outputs.\n\n\
1024         Server-streaming and bidi-streaming methods return\n\
1025         `ServiceStream<impl Encodable<Out> + Send + use<Self>>`. The\n\
1026         `use<Self>` precise-capturing clause excludes `&self`'s lifetime\n\
1027         (unary methods use `use<'a, Self>` and may borrow), so stream items\n\
1028         must be `'static`. To stream view-encoded data, encode each item\n\
1029         inside the stream body and yield\n\
1030         [`PreEncoded`](::connectrpc::PreEncoded) — see its `# Streaming\n\
1031         example` doc."
1032    );
1033    let service_doc_tokens = doc_attrs(&full_doc);
1034
1035    // Generate trait methods
1036    let trait_methods: Vec<TokenStream> = service
1037        .method
1038        .iter()
1039        .map(|m| generate_trait_method(file, service, m, resolver, batch, package))
1040        .collect::<Result<Vec<_>>>()?;
1041
1042    // Generate route registrations for extension trait
1043    let route_registrations: Vec<TokenStream> = service
1044        .method
1045        .iter()
1046        .map(|m| {
1047            let method_name = m.name.as_deref().unwrap_or("");
1048            let method_snake = make_field_ident(&method_name.to_snake_case());
1049            // Attach the per-method `Spec` const so the dynamic `Router`
1050            // surfaces `RequestContext::spec()` exactly like the
1051            // monomorphic `FooServiceServer<T>` dispatcher does.
1052            let spec_const = method_spec_const_ident(service, method_name);
1053
1054            let client_streaming = m.client_streaming.unwrap_or(false);
1055            let server_streaming = m.server_streaming.unwrap_or(false);
1056
1057            let route_call = if server_streaming && !client_streaming {
1058                // Server streaming method. The trait method returns
1059                // `ServiceStream<impl Encodable<Out>>`; `Res = Out` is no
1060                // longer derivable from the opaque item type, so it must
1061                // be turbofished.
1062                let output_type = resolver
1063                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1064                    .unwrap();
1065                quote! {
1066                    .route_view_server_stream::<_, _, #output_type>(
1067                        #service_name_const,
1068                        #method_name,
1069                        ::connectrpc::view_streaming_handler_fn({
1070                            let svc = ::std::sync::Arc::clone(&self);
1071                            move |ctx, req| {
1072                                let svc = ::std::sync::Arc::clone(&svc);
1073                                async move { svc.#method_snake(ctx, req).await }
1074                            }
1075                        }),
1076                    )
1077                }
1078            } else if client_streaming && !server_streaming {
1079                // Client streaming method
1080                let output_type = resolver
1081                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1082                    .unwrap();
1083                quote! {
1084                    .route_view_client_stream(
1085                        #service_name_const,
1086                        #method_name,
1087                        ::connectrpc::view_client_streaming_handler_fn({
1088                            let svc = ::std::sync::Arc::clone(&self);
1089                            move |ctx, req, format| {
1090                                let svc = ::std::sync::Arc::clone(&svc);
1091                                async move {
1092                                    svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1093                                }
1094                            }
1095                        }),
1096                    )
1097                }
1098            } else if client_streaming && server_streaming {
1099                // Bidi streaming method. Same turbofish need as server
1100                // streaming above.
1101                let output_type = resolver
1102                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1103                    .unwrap();
1104                quote! {
1105                    .route_view_bidi_stream::<_, _, #output_type>(
1106                        #service_name_const,
1107                        #method_name,
1108                        ::connectrpc::view_bidi_streaming_handler_fn({
1109                            let svc = ::std::sync::Arc::clone(&self);
1110                            move |ctx, req| {
1111                                let svc = ::std::sync::Arc::clone(&svc);
1112                                async move { svc.#method_snake(ctx, req).await }
1113                            }
1114                        }),
1115                    )
1116                }
1117            } else {
1118                // Unary method
1119                let is_idempotent = m
1120                    .options
1121                    .idempotency_level
1122                    .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1123                    .unwrap_or(false);
1124
1125                let route_method = if is_idempotent {
1126                    quote! { route_view_idempotent }
1127                } else {
1128                    quote! { route_view }
1129                };
1130                let output_type = resolver
1131                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1132                    .unwrap();
1133
1134                quote! {
1135                    .#route_method(
1136                        #service_name_const,
1137                        #method_name,
1138                        {
1139                            let svc = ::std::sync::Arc::clone(&self);
1140                            ::connectrpc::view_handler_fn(move |ctx, req, format| {
1141                                let svc = ::std::sync::Arc::clone(&svc);
1142                                async move {
1143                                    svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1144                                }
1145                            })
1146                        },
1147                    )
1148                }
1149            };
1150
1151            quote! {
1152                #route_call
1153                .with_spec(#spec_const)
1154            }
1155        })
1156        .collect();
1157
1158    // Generate client methods
1159    let client_methods: Vec<TokenStream> = service
1160        .method
1161        .iter()
1162        .map(|m| {
1163            generate_client_method(
1164                &service_name_const,
1165                &full_service_name,
1166                m,
1167                resolver,
1168                package,
1169            )
1170        })
1171        .collect::<Result<Vec<_>>>()?;
1172
1173    // Generate monomorphic FooServiceServer<T> dispatcher.
1174    let service_server = generate_service_server(
1175        &full_service_name,
1176        &trait_name,
1177        &server_name,
1178        service,
1179        resolver,
1180        package,
1181    )?;
1182
1183    // Example method name for client doc
1184    let example_method = service
1185        .method
1186        .first()
1187        .and_then(|m| m.name.as_deref())
1188        .map(|n| make_field_ident(&n.to_snake_case()).to_string())
1189        .unwrap_or_else(|| "method".to_string());
1190
1191    // Build client doc comment with interpolated example method
1192    let client_name_str = client_name.to_string();
1193    let client_doc = format!(
1194        r#"Client for this service.
1195
1196Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
1197`Http2Connection` — it has honest `poll_ready` and composes with
1198`tower::balance` for multi-connection load balancing. For **Connect
1199over HTTP/1.1** (or unknown protocol), use `HttpClient`.
1200
1201# Example (gRPC / HTTP/2)
1202
1203```rust,ignore
1204use connectrpc::client::{{Http2Connection, ClientConfig}};
1205use connectrpc::Protocol;
1206
1207let uri: http::Uri = "http://localhost:8080".parse()?;
1208let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
1209let config = ClientConfig::new(uri).with_protocol(Protocol::Grpc);
1210
1211let client = {client_name_str}::new(conn, config);
1212let response = client.{example_method}(request).await?;
1213```
1214
1215# Example (Connect / HTTP/1.1 or ALPN)
1216
1217```rust,ignore
1218use connectrpc::client::{{HttpClient, ClientConfig}};
1219
1220let http = HttpClient::plaintext();  // cleartext http:// only
1221let config = ClientConfig::new("http://localhost:8080".parse()?);
1222
1223let client = {client_name_str}::new(http, config);
1224let response = client.{example_method}(request).await?;
1225```
1226
1227# Working with the response
1228
1229Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
1230The `OwnedView` derefs to the view, so field access is zero-copy:
1231
1232```rust,ignore
1233let resp = client.{example_method}(request).await?.into_view();
1234let name: &str = resp.name;  // borrow into the response buffer
1235```
1236
1237If you need the owned struct (e.g. to store or pass by value), use
1238[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
1239
1240```rust,ignore
1241let owned = client.{example_method}(request).await?.into_owned();
1242```"#
1243    );
1244    let client_doc_tokens = doc_attrs(&client_doc);
1245
1246    // Per-method `Spec` constants. Stable, allocation-free metadata that the
1247    // dispatcher threads into `RequestContext::spec` and that user code can
1248    // reference directly (e.g. for tracing labels or routing tables).
1249    let spec_consts = generate_spec_consts(&full_service_name, service);
1250
1251    Ok(quote! {
1252        // -----------------------------------------------------------------------------
1253        // #service_name
1254        // -----------------------------------------------------------------------------
1255
1256        /// Full service name for this service.
1257        pub const #service_name_const: &str = #full_service_name;
1258
1259        #(#spec_consts)*
1260
1261        #service_doc_tokens
1262        #[allow(clippy::type_complexity)]
1263        pub trait #trait_name: Send + Sync + 'static {
1264            #(#trait_methods)*
1265        }
1266
1267        /// Extension trait for registering a service implementation with a Router.
1268        ///
1269        /// This trait is automatically implemented for all types that implement the service trait.
1270        ///
1271        /// # Example
1272        ///
1273        /// ```rust,ignore
1274        /// use std::sync::Arc;
1275        ///
1276        /// let service = Arc::new(MyServiceImpl);
1277        /// let router = service.register(Router::new());
1278        /// ```
1279        pub trait #ext_trait_name: #trait_name {
1280            /// Register this service implementation with a Router.
1281            ///
1282            /// Takes ownership of the `Arc<Self>` and returns a new Router with
1283            /// this service's methods registered.
1284            fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router;
1285        }
1286
1287        impl<S: #trait_name> #ext_trait_name for S {
1288            fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router {
1289                router
1290                    #(#route_registrations)*
1291            }
1292        }
1293
1294        #service_server
1295
1296        #client_doc_tokens
1297        #[derive(Clone)]
1298        pub struct #client_name<T> {
1299            transport: T,
1300            config: ::connectrpc::client::ClientConfig,
1301        }
1302
1303        impl<T> #client_name<T>
1304        where
1305            T: ::connectrpc::client::ClientTransport,
1306            <T::ResponseBody as ::http_body::Body>::Error: ::std::fmt::Display,
1307        {
1308            /// Create a new client with the given transport and configuration.
1309            pub fn new(transport: T, config: ::connectrpc::client::ClientConfig) -> Self {
1310                Self { transport, config }
1311            }
1312
1313            /// Get the client configuration.
1314            pub fn config(&self) -> &::connectrpc::client::ClientConfig {
1315                &self.config
1316            }
1317
1318            /// Get a mutable reference to the client configuration.
1319            pub fn config_mut(&mut self) -> &mut ::connectrpc::client::ClientConfig {
1320                &mut self.config
1321            }
1322
1323            #(#client_methods)*
1324        }
1325    })
1326}
1327
1328/// Construct the identifier for a per-method `Spec` constant.
1329///
1330/// The name is derived from the service and method names, e.g.
1331/// `ELIZA_SERVICE_SAY_SPEC` for `ElizaService.Say`. Lives at module scope so
1332/// both the server dispatcher and (later) the generated client can reference
1333/// the same constant.
1334fn method_spec_const_ident(service: &ServiceDescriptorProto, method_name: &str) -> Ident {
1335    let service_name = service.name.as_deref().unwrap_or("");
1336    format_ident!(
1337        "{}_{}_SPEC",
1338        service_name.to_snake_case().to_uppercase(),
1339        method_name.to_snake_case().to_uppercase()
1340    )
1341}
1342
1343/// Emit one `pub const … : ::connectrpc::Spec` per method.
1344///
1345/// Each constant captures the method's procedure path, stream type, and
1346/// idempotency level. Constructed via `Spec::server(...)` so
1347/// `Spec::origin == SpecOrigin::Server`; a future generated client will
1348/// emit a sibling constant via `Spec::client(...)`. The constants are
1349/// referenced by the generated `Dispatcher::lookup` impl and are also
1350/// stable public API for user code.
1351fn generate_spec_consts(
1352    full_service_name: &str,
1353    service: &ServiceDescriptorProto,
1354) -> Vec<TokenStream> {
1355    service
1356        .method
1357        .iter()
1358        .map(|m| {
1359            let method_name = m.name.as_deref().unwrap_or("");
1360            let spec_const = method_spec_const_ident(service, method_name);
1361            let procedure = format!("/{full_service_name}/{method_name}");
1362            let cs = m.client_streaming.unwrap_or(false);
1363            let ss = m.server_streaming.unwrap_or(false);
1364            let stream_type = match (cs, ss) {
1365                (true, true) => quote! { ::connectrpc::StreamType::BidiStream },
1366                (true, false) => quote! { ::connectrpc::StreamType::ClientStream },
1367                (false, true) => quote! { ::connectrpc::StreamType::ServerStream },
1368                (false, false) => quote! { ::connectrpc::StreamType::Unary },
1369            };
1370            let idempotency_level = match m.options.idempotency_level {
1371                Some(IdempotencyLevel::NO_SIDE_EFFECTS) => {
1372                    quote! { ::connectrpc::IdempotencyLevel::NoSideEffects }
1373                }
1374                Some(IdempotencyLevel::IDEMPOTENT) => {
1375                    quote! { ::connectrpc::IdempotencyLevel::Idempotent }
1376                }
1377                _ => quote! { ::connectrpc::IdempotencyLevel::Unknown },
1378            };
1379            let doc = format!(
1380                "Static [`Spec`](::connectrpc::Spec) for the server-side `{method_name}` RPC.\n\n\
1381                 The dispatcher surfaces this on\n\
1382                 [`RequestContext::spec`](::connectrpc::RequestContext::spec)."
1383            );
1384            let doc_tokens = doc_attrs(&doc);
1385            quote! {
1386                #doc_tokens
1387                pub const #spec_const: ::connectrpc::Spec =
1388                    ::connectrpc::Spec::server(#procedure, #stream_type)
1389                        .with_idempotency_level(#idempotency_level);
1390            }
1391        })
1392        .collect()
1393}
1394
1395/// Generate a monomorphic `FooServiceServer<T>` struct and its `Dispatcher` impl.
1396///
1397/// This is the fast-path alternative to `FooServiceExt::register(Router)`: instead
1398/// of type-erasing each method behind `Arc<dyn ErasedHandler>` and looking them up
1399/// in a `HashMap`, this struct dispatches via a compile-time `match` on method name
1400/// with no trait objects or hash lookups in the hot path.
1401fn generate_service_server(
1402    full_service_name: &str,
1403    trait_name: &proc_macro2::Ident,
1404    server_name: &proc_macro2::Ident,
1405    service: &ServiceDescriptorProto,
1406    resolver: &TypeResolver<'_>,
1407    package: &str,
1408) -> Result<TokenStream> {
1409    // Path prefix matched by `dispatch` / `call_*`: "pkg.Service/"
1410    let path_prefix = format!("{full_service_name}/");
1411
1412    // Per-method match arms for `lookup(path)`.
1413    let lookup_arms: Vec<TokenStream> = service
1414        .method
1415        .iter()
1416        .map(|m| {
1417            let method_name = m.name.as_deref().unwrap_or("");
1418            let client_streaming = m.client_streaming.unwrap_or(false);
1419            let server_streaming = m.server_streaming.unwrap_or(false);
1420            let is_idempotent = m
1421                .options
1422                .idempotency_level
1423                .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1424                .unwrap_or(false);
1425            let spec_const = method_spec_const_ident(service, method_name);
1426
1427            let desc = if client_streaming && server_streaming {
1428                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::bidi_streaming() }
1429            } else if client_streaming {
1430                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::client_streaming() }
1431            } else if server_streaming {
1432                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::server_streaming() }
1433            } else {
1434                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::unary(#is_idempotent) }
1435            };
1436            quote! { #method_name => Some(#desc.with_spec(#spec_const)), }
1437        })
1438        .collect();
1439
1440    // Per-kind match arms for the four `call_*` methods.
1441    // Each `call_*` only includes arms for methods of the matching kind; other
1442    // paths fall through to `unimplemented_*` (the caller checked `lookup()`
1443    // first, so this is a defensive-only branch).
1444    let mut call_unary_arms: Vec<TokenStream> = Vec::new();
1445    let mut call_ss_arms: Vec<TokenStream> = Vec::new();
1446    let mut call_cs_arms: Vec<TokenStream> = Vec::new();
1447    let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
1448
1449    for m in &service.method {
1450        let method_name = m.name.as_deref().unwrap_or("");
1451        let method_snake = make_field_ident(&method_name.to_snake_case());
1452        let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
1453        let output_type = resolver.rust_type(m.output_type.as_deref().unwrap_or(""), package)?;
1454        let cs = m.client_streaming.unwrap_or(false);
1455        let ss = m.server_streaming.unwrap_or(false);
1456
1457        if cs && ss {
1458            // Bidi streaming
1459            call_bidi_arms.push(quote! {
1460                #method_name => {
1461                    let svc = ::std::sync::Arc::clone(&self.inner);
1462                    Box::pin(async move {
1463                        let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
1464                        let resp = svc.#method_snake(ctx, req_stream).await?;
1465                        Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1466                    })
1467                }
1468            });
1469        } else if cs {
1470            // Client streaming
1471            call_cs_arms.push(quote! {
1472                #method_name => {
1473                    let svc = ::std::sync::Arc::clone(&self.inner);
1474                    Box::pin(async move {
1475                        let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
1476                        svc.#method_snake(ctx, req_stream).await?.encode::<#output_type>(format)
1477                    })
1478                }
1479            });
1480        } else if ss {
1481            // Server streaming
1482            call_ss_arms.push(quote! {
1483                #method_name => {
1484                    let svc = ::std::sync::Arc::clone(&self.inner);
1485                    Box::pin(async move {
1486                        let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request, format)?;
1487                        let resp = svc.#method_snake(ctx, req).await?;
1488                        Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1489                    })
1490                }
1491            });
1492        } else {
1493            // Unary
1494            call_unary_arms.push(quote! {
1495                #method_name => {
1496                    let svc = ::std::sync::Arc::clone(&self.inner);
1497                    Box::pin(async move {
1498                        // Generated handlers are view-based, so the owned-message
1499                        // cache an interceptor may have populated cannot be reused.
1500                        // `encoded()` returns the (post-replacement) wire bytes —
1501                        // a cheap `Bytes` clone for the common no-replacement case.
1502                        let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request.encoded()?, format)?;
1503                        svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1504                    })
1505                }
1506            });
1507        }
1508    }
1509
1510    let server_doc = format!(
1511        "Monomorphic dispatcher for `{trait_name}`.\n\n\
1512         Unlike `.register(Router)` which type-erases each method into an \
1513         `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
1514         via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
1515         # Example\n\n\
1516         ```rust,ignore\n\
1517         use connectrpc::ConnectRpcService;\n\n\
1518         let server = {server_name}::new(MyImpl);\n\
1519         let service = ConnectRpcService::new(server);\n\
1520         // hand `service` to axum/hyper as a fallback_service\n\
1521         ```"
1522    );
1523    let server_doc_tokens = doc_attrs(&server_doc);
1524
1525    Ok(quote! {
1526        #server_doc_tokens
1527        pub struct #server_name<T> {
1528            inner: ::std::sync::Arc<T>,
1529        }
1530
1531        impl<T: #trait_name> #server_name<T> {
1532            /// Wrap a service implementation in a monomorphic dispatcher.
1533            pub fn new(service: T) -> Self {
1534                Self { inner: ::std::sync::Arc::new(service) }
1535            }
1536
1537            /// Wrap an already-`Arc`'d service implementation.
1538            pub fn from_arc(inner: ::std::sync::Arc<T>) -> Self {
1539                Self { inner }
1540            }
1541        }
1542
1543        impl<T> Clone for #server_name<T> {
1544            fn clone(&self) -> Self {
1545                Self { inner: ::std::sync::Arc::clone(&self.inner) }
1546            }
1547        }
1548
1549        impl<T: #trait_name> ::connectrpc::Dispatcher for #server_name<T> {
1550            #[inline]
1551            fn lookup(&self, path: &str) -> Option<::connectrpc::dispatcher::codegen::MethodDescriptor> {
1552                let method = path.strip_prefix(#path_prefix)?;
1553                match method {
1554                    #(#lookup_arms)*
1555                    _ => None,
1556                }
1557            }
1558
1559            fn call_unary(
1560                &self,
1561                path: &str,
1562                ctx: ::connectrpc::RequestContext,
1563                request: ::connectrpc::Payload,
1564                format: ::connectrpc::CodecFormat,
1565            ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
1566                let Some(method) = path.strip_prefix(#path_prefix) else {
1567                    return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
1568                };
1569                // Suppress unused warnings when this service has no unary methods.
1570                let _ = (&ctx, &request, &format);
1571                match method {
1572                    #(#call_unary_arms)*
1573                    _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
1574                }
1575            }
1576
1577            fn call_server_streaming(
1578                &self,
1579                path: &str,
1580                ctx: ::connectrpc::RequestContext,
1581                request: ::buffa::bytes::Bytes,
1582                format: ::connectrpc::CodecFormat,
1583            ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
1584                let Some(method) = path.strip_prefix(#path_prefix) else {
1585                    return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
1586                };
1587                let _ = (&ctx, &request, &format);
1588                match method {
1589                    #(#call_ss_arms)*
1590                    _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
1591                }
1592            }
1593
1594            fn call_client_streaming(
1595                &self,
1596                path: &str,
1597                ctx: ::connectrpc::RequestContext,
1598                requests: ::connectrpc::dispatcher::codegen::RequestStream,
1599                format: ::connectrpc::CodecFormat,
1600            ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
1601                let Some(method) = path.strip_prefix(#path_prefix) else {
1602                    return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
1603                };
1604                let _ = (&ctx, &requests, &format);
1605                match method {
1606                    #(#call_cs_arms)*
1607                    _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
1608                }
1609            }
1610
1611            fn call_bidi_streaming(
1612                &self,
1613                path: &str,
1614                ctx: ::connectrpc::RequestContext,
1615                requests: ::connectrpc::dispatcher::codegen::RequestStream,
1616                format: ::connectrpc::CodecFormat,
1617            ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
1618                let Some(method) = path.strip_prefix(#path_prefix) else {
1619                    return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
1620                };
1621                let _ = (&ctx, &requests, &format);
1622                match method {
1623                    #(#call_bidi_arms)*
1624                    _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
1625                }
1626            }
1627        }
1628    })
1629}
1630
1631/// Generate documentation comment tokens.
1632fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
1633    let comment = if doc.is_empty() { default } else { doc };
1634    doc_attrs(comment)
1635}
1636
1637/// Generate a trait method for a service.
1638fn generate_trait_method(
1639    file: &FileDescriptorProto,
1640    service: &ServiceDescriptorProto,
1641    method: &MethodDescriptorProto,
1642    resolver: &TypeResolver<'_>,
1643    batch: &BatchState,
1644    package: &str,
1645) -> Result<TokenStream> {
1646    let method_name = method.name.as_deref().unwrap_or("");
1647    let method_snake = make_field_ident(&method_name.to_snake_case());
1648    let input_arg = owned_view_input_arg_type(
1649        resolver,
1650        batch,
1651        method.input_type.as_deref().unwrap_or(""),
1652        package,
1653    )?;
1654    let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
1655
1656    // Get method documentation
1657    let method_doc = get_method_comment(file, service, method).unwrap_or_default();
1658    let method_doc_tokens =
1659        generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
1660
1661    // Check for streaming
1662    let client_streaming = method.client_streaming.unwrap_or(false);
1663    let server_streaming = method.server_streaming.unwrap_or(false);
1664
1665    let borrow_doc = quote! {
1666        #[doc = ""]
1667        #[doc = " `'a` lets the response body borrow from `&self` (e.g. server-resident state)."]
1668    };
1669
1670    if server_streaming && !client_streaming {
1671        // Server streaming method. `impl Encodable<...>` lets the handler
1672        // yield `Res`, `PreEncoded`, or `MaybeBorrowed` items — same
1673        // flexibility as the unary `impl Encodable<...>` body bound.
1674        // `use<Self>` opts out of capturing `&self`'s lifetime (RPITITs in
1675        // trait methods otherwise capture it by default), since stream
1676        // items have to be `'static`. Without it, the generated route
1677        // registration's `Arc::clone` closures fail E0597.
1678        Ok(quote! {
1679            #method_doc_tokens
1680            fn #method_snake(
1681                &self,
1682                ctx: ::connectrpc::RequestContext,
1683                request: #input_arg,
1684            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
1685        })
1686    } else if client_streaming && !server_streaming {
1687        // Client streaming method
1688        Ok(quote! {
1689            #method_doc_tokens
1690            #borrow_doc
1691            fn #method_snake<'a>(
1692                &'a self,
1693                ctx: ::connectrpc::RequestContext,
1694                requests: ::connectrpc::ServiceStream<#input_arg>,
1695            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
1696        })
1697    } else if client_streaming && server_streaming {
1698        // Bidi streaming method. Same `impl Encodable<...>` item type and
1699        // `use<Self>` capture clause as server streaming above.
1700        Ok(quote! {
1701            #method_doc_tokens
1702            fn #method_snake(
1703                &self,
1704                ctx: ::connectrpc::RequestContext,
1705                requests: ::connectrpc::ServiceStream<#input_arg>,
1706            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
1707        })
1708    } else {
1709        // Unary method
1710        Ok(quote! {
1711            #method_doc_tokens
1712            #borrow_doc
1713            fn #method_snake<'a>(
1714                &'a self,
1715                ctx: ::connectrpc::RequestContext,
1716                request: #input_arg,
1717            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
1718        })
1719    }
1720}
1721
1722/// Generate client method(s) for a service RPC.
1723///
1724/// Emits two methods per RPC:
1725///   - `<method_snake>(&self, ...)` — no-options convenience, delegates to `_with_options`
1726///   - `<method_snake>_with_options(&self, ..., options: CallOptions)` — explicit options
1727///
1728/// This gives callers an ergonomic default while still surfacing per-call
1729/// control. The library's `effective_options()` merges options over
1730/// ClientConfig defaults, so the no-options variant still picks up any
1731/// client-wide defaults the user configured.
1732fn generate_client_method(
1733    service_name_const: &Ident,
1734    full_service_name: &str,
1735    method: &MethodDescriptorProto,
1736    resolver: &TypeResolver<'_>,
1737    package: &str,
1738) -> Result<TokenStream> {
1739    let method_name = method.name.as_deref().unwrap_or("");
1740    let method_snake = make_field_ident(&method_name.to_snake_case());
1741    let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
1742    let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
1743    let output_view_type =
1744        resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
1745
1746    let client_streaming = method.client_streaming.unwrap_or(false);
1747    let server_streaming = method.server_streaming.unwrap_or(false);
1748
1749    let doc = format!(
1750        " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
1751    );
1752    let doc_opts = format!(
1753        " Call the {method_name} RPC with explicit per-call options. \
1754         Options override [`ClientConfig`](::connectrpc::client::ClientConfig) defaults."
1755    );
1756
1757    // Return type is protocol-specific. Compute once.
1758    let ret_ty: TokenStream;
1759    let call_body: TokenStream;
1760    let short_args: TokenStream; // args to the no-opts convenience method
1761    let opts_args: TokenStream; // args to the _with_options method
1762    let short_delegate_args: TokenStream; // how short delegates to opts
1763
1764    if client_streaming && !server_streaming {
1765        // Client-stream
1766        ret_ty = quote! {
1767            Result<
1768                ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
1769                ::connectrpc::ConnectError,
1770            >
1771        };
1772        call_body = quote! {
1773            ::connectrpc::client::call_client_stream(
1774                &self.transport, &self.config,
1775                #service_name_const, #method_name,
1776                requests, options,
1777            ).await
1778        };
1779        short_args = quote! { requests: impl IntoIterator<Item = #input_type> };
1780        opts_args = quote! { requests: impl IntoIterator<Item = #input_type>, options: ::connectrpc::client::CallOptions };
1781        short_delegate_args = quote! { requests, ::connectrpc::client::CallOptions::default() };
1782    } else if client_streaming && server_streaming {
1783        // Bidi
1784        ret_ty = quote! {
1785            Result<
1786                ::connectrpc::client::BidiStream<
1787                    T::ResponseBody, #input_type, #output_view_type<'static>
1788                >,
1789                ::connectrpc::ConnectError,
1790            >
1791        };
1792        call_body = quote! {
1793            ::connectrpc::client::call_bidi_stream(
1794                &self.transport, &self.config,
1795                #service_name_const, #method_name, options,
1796            ).await
1797        };
1798        short_args = quote! {};
1799        opts_args = quote! { options: ::connectrpc::client::CallOptions };
1800        short_delegate_args = quote! { ::connectrpc::client::CallOptions::default() };
1801    } else if server_streaming {
1802        // Server-stream
1803        ret_ty = quote! {
1804            Result<
1805                ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
1806                ::connectrpc::ConnectError,
1807            >
1808        };
1809        call_body = quote! {
1810            ::connectrpc::client::call_server_stream(
1811                &self.transport, &self.config,
1812                #service_name_const, #method_name,
1813                request, options,
1814            ).await
1815        };
1816        short_args = quote! { request: #input_type };
1817        opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
1818        short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
1819    } else {
1820        // Unary
1821        ret_ty = quote! {
1822            Result<
1823                ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
1824                ::connectrpc::ConnectError,
1825            >
1826        };
1827        call_body = quote! {
1828            ::connectrpc::client::call_unary(
1829                &self.transport, &self.config,
1830                #service_name_const, #method_name,
1831                request, options,
1832            ).await
1833        };
1834        short_args = quote! { request: #input_type };
1835        opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
1836        short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
1837    }
1838
1839    Ok(quote! {
1840        #[doc = #doc]
1841        pub async fn #method_snake(&self, #short_args) -> #ret_ty {
1842            self.#method_with_opts(#short_delegate_args).await
1843        }
1844
1845        #[doc = #doc_opts]
1846        pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
1847            #call_body
1848        }
1849    })
1850}
1851
1852/// Get the documentation comment for a service.
1853fn get_service_comment(
1854    file: &FileDescriptorProto,
1855    service: &ServiceDescriptorProto,
1856) -> Option<String> {
1857    // MessageField derefs to default when unset; default has empty location vec
1858    let source_info: &SourceCodeInfo = &file.source_code_info;
1859
1860    // Find service index
1861    let service_index = file.service.iter().position(|s| s.name == service.name)?;
1862
1863    // Path for service: [6, service_index]
1864    // 6 = service field number in FileDescriptorProto
1865    let target_path = vec![6, service_index as i32];
1866
1867    find_comment(source_info, &target_path)
1868}
1869
1870/// Get the documentation comment for a method.
1871fn get_method_comment(
1872    file: &FileDescriptorProto,
1873    service: &ServiceDescriptorProto,
1874    method: &MethodDescriptorProto,
1875) -> Option<String> {
1876    let source_info: &SourceCodeInfo = &file.source_code_info;
1877
1878    // Find service and method indices, matching on the parent service name
1879    // to avoid ambiguity when multiple services have methods with the same name.
1880    let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
1881        if s.name != service.name {
1882            return None;
1883        }
1884        s.method
1885            .iter()
1886            .position(|m| m.name == method.name)
1887            .map(|mi| (si, mi))
1888    })?;
1889
1890    // Path for method: [6, service_index, 2, method_index]
1891    // 6 = service field number in FileDescriptorProto
1892    // 2 = method field number in ServiceDescriptorProto
1893    let target_path = vec![6, service_index as i32, 2, method_index as i32];
1894
1895    find_comment(source_info, &target_path)
1896}
1897
1898/// Find a comment in source code info for the given path.
1899fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
1900    for location in &source_info.location {
1901        if location.path == target_path {
1902            let comment = location
1903                .leading_comments
1904                .as_ref()
1905                .or(location.trailing_comments.as_ref())?;
1906
1907            // Trim each line; blank lines are dropped (protoc's convention
1908            // uses a leading space we don't need here — `doc_attrs` adds
1909            // its own uniform leading space for prettyplease rendering).
1910            let cleaned: String = comment
1911                .lines()
1912                .map(|line| line.trim())
1913                .filter(|line| !line.is_empty())
1914                .collect::<Vec<_>>()
1915                .join("\n");
1916
1917            if !cleaned.is_empty() {
1918                return Some(cleaned);
1919            }
1920        }
1921    }
1922    None
1923}
1924
1925#[cfg(test)]
1926mod tests {
1927    use super::*;
1928    use buffa_codegen::generated::descriptor::DescriptorProto;
1929
1930    #[test]
1931    fn doc_attrs_prefixes_space_for_prettyplease() {
1932        // prettyplease emits `#[doc = "X"]` as `///X` verbatim. We prefix
1933        // each non-blank line with a space so the output is `/// X`.
1934        let ts = quote! {
1935            #[allow(dead_code)]
1936            mod m {}
1937        };
1938        let doc = doc_attrs("Hello.\n\nSecond paragraph.");
1939        let combined = quote! { #doc #ts };
1940        let file = syn::parse2::<syn::File>(combined).unwrap();
1941        let out = prettyplease::unparse(&file);
1942        // Each non-blank line should have a space after ///.
1943        assert!(out.contains("/// Hello."), "got: {out}");
1944        assert!(out.contains("/// Second paragraph."), "got: {out}");
1945        // Blank line becomes bare /// (paragraph break).
1946        assert!(out.contains("///\n"), "got: {out}");
1947        // Should NOT contain ///H (no space) or ///  H (double space).
1948        assert!(!out.contains("///Hello"), "got: {out}");
1949        assert!(!out.contains("///  Hello"), "got: {out}");
1950    }
1951
1952    /// Build a minimal proto file with one message type and one service method.
1953    /// The service method's input/output types are fully-qualified proto names
1954    /// (e.g. `.example.v1.PingReq` or `.google.protobuf.Empty`) so the resolver
1955    /// can look them up.
1956    fn minimal_file(
1957        package: Option<&str>,
1958        input_type: &str,
1959        output_type: &str,
1960        local_messages: &[&str],
1961    ) -> FileDescriptorProto {
1962        minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
1963    }
1964
1965    /// Like [`minimal_file`] but with a custom RPC method name, for testing
1966    /// keyword collisions and other name-derived behaviour.
1967    fn minimal_file_with_method(
1968        package: Option<&str>,
1969        method_name: &str,
1970        input_type: &str,
1971        output_type: &str,
1972        local_messages: &[&str],
1973    ) -> FileDescriptorProto {
1974        let method = MethodDescriptorProto {
1975            name: Some(method_name.into()),
1976            input_type: Some(input_type.into()),
1977            output_type: Some(output_type.into()),
1978            ..Default::default()
1979        };
1980        let service = ServiceDescriptorProto {
1981            name: Some("PingService".into()),
1982            method: vec![method],
1983            ..Default::default()
1984        };
1985        FileDescriptorProto {
1986            name: Some("ping.proto".into()),
1987            package: package.map(|p| p.into()),
1988            service: vec![service],
1989            message_type: local_messages
1990                .iter()
1991                .map(|name| DescriptorProto {
1992                    name: Some((*name).into()),
1993                    ..Default::default()
1994                })
1995                .collect(),
1996            ..Default::default()
1997        }
1998    }
1999
2000    /// Build a minimal proto file with one service holding the given method
2001    /// names, all typed `Empty` -> `Empty`. Used for collision tests where
2002    /// the method *names* are what's under test.
2003    fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
2004        let methods = method_names
2005            .iter()
2006            .map(|n| MethodDescriptorProto {
2007                name: Some((*n).into()),
2008                input_type: Some(format!(".{package}.Empty")),
2009                output_type: Some(format!(".{package}.Empty")),
2010                ..Default::default()
2011            })
2012            .collect();
2013        let service = ServiceDescriptorProto {
2014            name: Some("PingService".into()),
2015            method: methods,
2016            ..Default::default()
2017        };
2018        FileDescriptorProto {
2019            name: Some("ping.proto".into()),
2020            package: Some(package.into()),
2021            service: vec![service],
2022            message_type: vec![DescriptorProto {
2023                name: Some("Empty".into()),
2024                ..Default::default()
2025            }],
2026            ..Default::default()
2027        }
2028    }
2029
2030    /// Generate service code for `files[target_idx]`. All files are visible
2031    /// to the resolver (as transitive deps via `--include_imports`), but
2032    /// only the target is in `file_to_generate` — mirroring real protoc use.
2033    ///
2034    /// `extern_paths` is wired into `CodeGenConfig.extern_paths` (which
2035    /// feeds the resolver's type_map via `effective_extern_paths`).
2036    /// `require_extern` selects unified (`false`, super::-relative) vs
2037    /// split (`true`, absolute-only) mode.
2038    fn gen_service(
2039        files: &[FileDescriptorProto],
2040        target_idx: usize,
2041        extern_paths: &[(String, String)],
2042        require_extern: bool,
2043    ) -> Result<String> {
2044        let mut config = buffa_codegen::CodeGenConfig::default();
2045        config.extern_paths = extern_paths.to_vec();
2046        let target_name = files[target_idx]
2047            .name
2048            .clone()
2049            .into_iter()
2050            .collect::<Vec<_>>();
2051        let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2052        let file = &files[target_idx];
2053        let service = &file.service[0];
2054        let batch = BatchState {
2055            colliding_aliases: collect_alias_collisions(files, &target_name),
2056            ..BatchState::default()
2057        };
2058        Ok(generate_service(file, service, &resolver, &batch)?.to_string())
2059    }
2060
2061    /// Assert that `formatted` (a Rust source string) contains no `use`
2062    /// items at the file root. Parses with `syn` rather than string-matching
2063    /// so doc comments, string literals, and indented `use` statements in
2064    /// nested modules cannot trigger false positives.
2065    fn assert_no_top_level_use(formatted: &str, label: &str) {
2066        let parsed: syn::File = syn::parse_str(formatted).expect("formatted code parses");
2067        let offenders: Vec<String> = parsed
2068            .items
2069            .iter()
2070            .filter_map(|item| match item {
2071                syn::Item::Use(u) => Some(quote!(#u).to_string()),
2072                _ => None,
2073            })
2074            .collect();
2075        assert!(
2076            offenders.is_empty(),
2077            "{label} contains top-level use statement(s): {offenders:?}\nFull source:\n{formatted}"
2078        );
2079    }
2080
2081    fn gen_file(
2082        files: &[FileDescriptorProto],
2083        target_idx: usize,
2084        extern_paths: &[(String, String)],
2085        require_extern: bool,
2086    ) -> Result<String> {
2087        let mut config = buffa_codegen::CodeGenConfig::default();
2088        config.extern_paths = extern_paths.to_vec();
2089        let target_name = files[target_idx]
2090            .name
2091            .clone()
2092            .into_iter()
2093            .collect::<Vec<_>>();
2094        let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2095        let mut batch = BatchState {
2096            colliding_aliases: collect_alias_collisions(files, &target_name),
2097            ..BatchState::default()
2098        };
2099        Ok(generate_connect_services(&files[target_idx], &resolver, &mut batch)?.to_string())
2100    }
2101
2102    #[test]
2103    fn unary_response_body_captures_self_lifetime() {
2104        let file = minimal_file(
2105            Some("example.v1"),
2106            ".example.v1.PingReq",
2107            ".example.v1.PingResp",
2108            &["PingReq", "PingResp"],
2109        );
2110        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2111        assert!(code.contains("< 'a >"), "trait method missing 'a: {code}");
2112        assert!(code.contains("& 'a self"), "missing &'a self: {code}");
2113        assert!(
2114            code.contains("use < 'a , Self >"),
2115            "missing use<'a, Self> capture: {code}"
2116        );
2117        assert!(
2118            !code.contains("'static + use"),
2119            "'static bound on body should be dropped: {code}"
2120        );
2121    }
2122
2123    #[test]
2124    fn owned_view_aliases_emitted_for_input_and_output() {
2125        let file = minimal_file(
2126            Some("example.v1"),
2127            ".example.v1.PingReq",
2128            ".example.v1.PingResp",
2129            &["PingReq", "PingResp"],
2130        );
2131        let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2132        assert!(
2133            code.contains("pub type OwnedPingReqView = :: buffa :: view :: OwnedView"),
2134            "missing OwnedPingReqView alias: {code}"
2135        );
2136        assert!(
2137            code.contains("pub type OwnedPingRespView = :: buffa :: view :: OwnedView"),
2138            "missing OwnedPingRespView alias: {code}"
2139        );
2140        // Trait method uses the alias for the request param.
2141        assert!(
2142            code.contains("request : OwnedPingReqView ,"),
2143            "trait method should take request: OwnedPingReqView: {code}"
2144        );
2145    }
2146
2147    #[test]
2148    fn cross_package_input_collision_suppresses_alias_for_both_sides() {
2149        // Regression test for #75. A service file that defines its own
2150        // `MyMessage` and also uses an imported `.api.v1.foo.bar.MyMessage`
2151        // as an RPC input previously emitted `pub type OwnedMyMessageView`
2152        // twice (once for the local output, once for the cross-package
2153        // input), failing to compile with E0428. The fix detects the
2154        // colliding alias name and inlines the `OwnedView<…<'static>>`
2155        // form for both members of the colliding set.
2156        let v1 = FileDescriptorProto {
2157            name: Some("api/v1/foo/bar/foobar.proto".into()),
2158            package: Some("api.v1.foo.bar".into()),
2159            message_type: vec![DescriptorProto {
2160                name: Some("MyMessage".into()),
2161                ..Default::default()
2162            }],
2163            ..Default::default()
2164        };
2165        let v2 = minimal_file(
2166            Some("api.v2.foo.bar"),
2167            ".api.v1.foo.bar.MyMessage",
2168            ".api.v2.foo.bar.MyMessage",
2169            &["MyMessage"],
2170        );
2171        let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2172
2173        // Neither side gets an alias because both would land at the same
2174        // identifier in the same module.
2175        let alias_count = code.matches("pub type OwnedMyMessageView").count();
2176        assert_eq!(
2177            alias_count, 0,
2178            "expected zero OwnedMyMessageView aliases when both sides collide; got {alias_count}: {code}"
2179        );
2180
2181        // Both colliding sides reach the trait sig as the inlined
2182        // `OwnedView<…<'static>>` form.
2183        assert!(
2184            !code.contains("request : OwnedMyMessageView"),
2185            "colliding input must not reference the suppressed alias: {code}"
2186        );
2187        assert!(
2188            code.contains("request : :: buffa :: view :: OwnedView <"),
2189            "colliding input should be inlined as OwnedView<…<'static>>: {code}"
2190        );
2191    }
2192
2193    #[test]
2194    fn cross_package_input_without_collision_keeps_alias() {
2195        // The #75 fix only suppresses aliases when two distinct FQNs in
2196        // the same target package would produce the same alias name. A
2197        // cross-package input with a unique short name (e.g. WKT inputs
2198        // like `.google.protobuf.Empty`) keeps its `OwnedEmptyView`
2199        // alias — generated handler code that previously read
2200        // `request: OwnedEmptyView` keeps working.
2201        let wkt = FileDescriptorProto {
2202            name: Some("google/protobuf/empty.proto".into()),
2203            package: Some("google.protobuf".into()),
2204            message_type: vec![DescriptorProto {
2205                name: Some("Empty".into()),
2206                ..Default::default()
2207            }],
2208            ..Default::default()
2209        };
2210        let svc = minimal_file(
2211            Some("example.v1"),
2212            ".google.protobuf.Empty",
2213            ".example.v1.PingResp",
2214            &["PingResp"],
2215        );
2216        let code = gen_file(&[wkt, svc], 1, &[], false).unwrap();
2217        assert!(
2218            code.contains("pub type OwnedEmptyView = :: buffa :: view :: OwnedView"),
2219            "WKT cross-package input should keep its alias: {code}"
2220        );
2221        assert!(
2222            code.contains("request : OwnedEmptyView ,"),
2223            "trait method should still use OwnedEmptyView for non-colliding cross-package input: {code}"
2224        );
2225    }
2226
2227    #[test]
2228    fn collision_inlines_in_all_streaming_method_shapes() {
2229        // The #75 fix substitutes `#input_arg` at four interpolation
2230        // sites in `generate_trait_method` (server-streaming, client-
2231        // streaming, bidi, unary). This drives all four shapes through
2232        // a colliding cross-package input to catch any regression that
2233        // accidentally drops the substitution from one branch.
2234        let v1 = FileDescriptorProto {
2235            name: Some("api/v1/foo/bar/foobar.proto".into()),
2236            package: Some("api.v1.foo.bar".into()),
2237            message_type: vec![DescriptorProto {
2238                name: Some("MyMessage".into()),
2239                ..Default::default()
2240            }],
2241            ..Default::default()
2242        };
2243        let v2 = FileDescriptorProto {
2244            name: Some("api/v2/foo/bar/foobar.proto".into()),
2245            package: Some("api.v2.foo.bar".into()),
2246            message_type: vec![DescriptorProto {
2247                name: Some("MyMessage".into()),
2248                ..Default::default()
2249            }],
2250            service: vec![ServiceDescriptorProto {
2251                name: Some("FooBar".into()),
2252                method: vec![
2253                    MethodDescriptorProto {
2254                        name: Some("Unary".into()),
2255                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2256                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2257                        ..Default::default()
2258                    },
2259                    MethodDescriptorProto {
2260                        name: Some("ServerStream".into()),
2261                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2262                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2263                        server_streaming: Some(true),
2264                        ..Default::default()
2265                    },
2266                    MethodDescriptorProto {
2267                        name: Some("ClientStream".into()),
2268                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2269                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2270                        client_streaming: Some(true),
2271                        ..Default::default()
2272                    },
2273                    MethodDescriptorProto {
2274                        name: Some("Bidi".into()),
2275                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2276                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2277                        client_streaming: Some(true),
2278                        server_streaming: Some(true),
2279                        ..Default::default()
2280                    },
2281                ],
2282                ..Default::default()
2283            }],
2284            ..Default::default()
2285        };
2286        let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2287
2288        // None of the four method shapes reference the suppressed alias.
2289        assert!(
2290            !code.contains("OwnedMyMessageView"),
2291            "no method shape should reference the suppressed alias: {code}"
2292        );
2293
2294        // Each method shape uses the inlined OwnedView<…<'static>> form.
2295        // Unary + server-streaming take a single request param; client-
2296        // streaming + bidi take a ServiceStream<…>.
2297        assert!(
2298            code.matches("request : :: buffa :: view :: OwnedView <")
2299                .count()
2300                >= 2,
2301            "unary and server-streaming should both inline the request type: {code}"
2302        );
2303        assert!(
2304            code.matches(
2305                "requests : :: connectrpc :: ServiceStream < :: buffa :: view :: OwnedView <"
2306            )
2307            .count()
2308                >= 2,
2309            "client-streaming and bidi should both inline the streamed request type: {code}"
2310        );
2311    }
2312
2313    #[test]
2314    fn streaming_methods_use_encodable_item_type() {
2315        // Server-streaming and bidi methods should declare their stream
2316        // item type as `impl Encodable<Out> + Send + use<Self>` rather than
2317        // the bare `Out`, so handlers can return `PreEncoded` /
2318        // `MaybeBorrowed` items. The dispatcher and route-registration
2319        // arms must both turbofish `Res` since `Encodable<M>` for
2320        // `PreEncoded` is generic over `M` (so `Res` is no longer
2321        // derivable from the opaque item type).
2322        let file = FileDescriptorProto {
2323            name: Some("ex/v1/svc.proto".into()),
2324            package: Some("ex.v1".into()),
2325            message_type: vec![
2326                DescriptorProto {
2327                    name: Some("Req".into()),
2328                    ..Default::default()
2329                },
2330                DescriptorProto {
2331                    name: Some("Resp".into()),
2332                    ..Default::default()
2333                },
2334            ],
2335            service: vec![ServiceDescriptorProto {
2336                name: Some("Svc".into()),
2337                method: vec![
2338                    MethodDescriptorProto {
2339                        name: Some("ServerStream".into()),
2340                        input_type: Some(".ex.v1.Req".into()),
2341                        output_type: Some(".ex.v1.Resp".into()),
2342                        server_streaming: Some(true),
2343                        ..Default::default()
2344                    },
2345                    MethodDescriptorProto {
2346                        name: Some("Bidi".into()),
2347                        input_type: Some(".ex.v1.Req".into()),
2348                        output_type: Some(".ex.v1.Resp".into()),
2349                        client_streaming: Some(true),
2350                        server_streaming: Some(true),
2351                        ..Default::default()
2352                    },
2353                ],
2354                ..Default::default()
2355            }],
2356            ..Default::default()
2357        };
2358        let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2359
2360        // Trait method declares `ServiceStream<impl Encodable<Resp> + ...>`.
2361        assert_eq!(
2362            code.matches(":: connectrpc :: ServiceStream < impl :: connectrpc :: Encodable < Resp > + Send + use < Self >>")
2363                .count(),
2364            2,
2365            "server-streaming and bidi should both use the Encodable item type: {code}"
2366        );
2367
2368        // Dispatcher arms turbofish `Res` to encode_response_stream.
2369        assert_eq!(
2370            code.matches("encode_response_stream :: < Resp , _ , _ >")
2371                .count(),
2372            2,
2373            "dispatcher arms must turbofish Res to encode_response_stream: {code}"
2374        );
2375
2376        // Route registrations turbofish `Res` to route_view_*_stream.
2377        assert!(
2378            code.contains("route_view_server_stream :: < _ , _ , Resp >"),
2379            "route_view_server_stream must turbofish Res: {code}"
2380        );
2381        assert!(
2382            code.contains("route_view_bidi_stream :: < _ , _ , Resp >"),
2383            "route_view_bidi_stream must turbofish Res: {code}"
2384        );
2385    }
2386
2387    #[test]
2388    fn encodable_view_impls_emitted_per_output_type() {
2389        let file = minimal_file(
2390            Some("example.v1"),
2391            ".example.v1.PingReq",
2392            ".example.v1.PingResp",
2393            &["PingReq", "PingResp"],
2394        );
2395        let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2396        assert!(
2397            code.contains(
2398                ":: connectrpc :: Encodable < PingResp > for __buffa :: view :: PingRespView"
2399            ),
2400            "missing Encodable<PingResp> for PingRespView: {code}"
2401        );
2402        assert!(
2403            code.contains(
2404                ":: connectrpc :: Encodable < PingResp > for :: buffa :: view :: OwnedView"
2405            ),
2406            "missing Encodable<PingResp> for OwnedView<PingRespView>: {code}"
2407        );
2408        // Input type should NOT get an impl (only output types).
2409        assert!(!code.contains("Encodable < PingReq >"), "got: {code}");
2410    }
2411
2412    #[test]
2413    fn encodable_view_impls_skipped_for_extern_output() {
2414        // Output type resolves via the WKT extern_path → ::buffa_types::...
2415        // so the impl would be an orphan; verify it's skipped.
2416        let wkt = FileDescriptorProto {
2417            name: Some("google/protobuf/empty.proto".into()),
2418            package: Some("google.protobuf".into()),
2419            message_type: vec![DescriptorProto {
2420                name: Some("Empty".into()),
2421                ..Default::default()
2422            }],
2423            ..Default::default()
2424        };
2425        let file = minimal_file(
2426            Some("example.v1"),
2427            ".example.v1.PingReq",
2428            ".google.protobuf.Empty",
2429            &["PingReq"],
2430        );
2431        let code = gen_file(&[wkt, file], 1, &[], false).unwrap();
2432        // The impl bodies call encode_view_body; the trait method's
2433        // `impl Encodable<M>` RPITIT bound doesn't.
2434        assert!(
2435            !code.contains("encode_view_body"),
2436            "extern output type must not get Encodable impl: {code}"
2437        );
2438    }
2439
2440    #[test]
2441    fn encodable_view_impls_deduped_across_files() {
2442        // Two service files in different packages both return
2443        // `.common.v1.Reply`. The stitcher mounts both files into one
2444        // module tree, so the Encodable<Reply> impls must be emitted
2445        // exactly once across the batch (else E0119).
2446        let common = FileDescriptorProto {
2447            name: Some("common.proto".into()),
2448            package: Some("common.v1".into()),
2449            message_type: vec![DescriptorProto {
2450                name: Some("Reply".into()),
2451                ..Default::default()
2452            }],
2453            ..Default::default()
2454        };
2455        let svc = |name: &str, pkg: &str| FileDescriptorProto {
2456            name: Some(name.into()),
2457            package: Some(pkg.into()),
2458            message_type: vec![DescriptorProto {
2459                name: Some("Req".into()),
2460                ..Default::default()
2461            }],
2462            service: vec![ServiceDescriptorProto {
2463                name: Some("S".into()),
2464                method: vec![MethodDescriptorProto {
2465                    name: Some("Call".into()),
2466                    input_type: Some(format!(".{pkg}.Req")),
2467                    output_type: Some(".common.v1.Reply".into()),
2468                    ..Default::default()
2469                }],
2470                ..Default::default()
2471            }],
2472            ..Default::default()
2473        };
2474        let files = vec![common, svc("a.proto", "a.v1"), svc("b.proto", "b.v1")];
2475
2476        let generated = generate_files(
2477            &files,
2478            &["a.proto".into(), "b.proto".into()],
2479            &Options::default(),
2480        )
2481        .unwrap();
2482
2483        // Each service-declaring proto produces exactly one Companion file
2484        // named `<stem>.__connect.rs`, wired into its package stitcher.
2485        let companions: Vec<_> = generated
2486            .iter()
2487            .filter(|f| f.kind == GeneratedFileKind::Companion)
2488            .collect();
2489        let mut companion_names: Vec<&str> = companions.iter().map(|f| f.name.as_str()).collect();
2490        companion_names.sort_unstable();
2491        assert_eq!(companion_names, ["a.__connect.rs", "b.__connect.rs"]);
2492        for c in &companions {
2493            let stitcher = generated
2494                .iter()
2495                .find(|g| g.kind == GeneratedFileKind::PackageMod && g.package == c.package)
2496                .expect("each companion's package must have a stitcher");
2497            assert!(
2498                stitcher
2499                    .content
2500                    .contains(&format!("include!(\"{}\")", c.name)),
2501                "stitcher for {} must include companion {}",
2502                c.package,
2503                c.name
2504            );
2505        }
2506
2507        let combined: String = companions.iter().map(|f| f.content.as_str()).collect();
2508
2509        let view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor super::super::common::v1::__buffa::view::ReplyView<'_>";
2510        let owned_view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor ::buffa::view::OwnedView<";
2511        assert_eq!(
2512            combined.matches(view_impl).count(),
2513            1,
2514            "Encodable<Reply> for ReplyView<'_> must appear once: {combined}"
2515        );
2516        assert_eq!(
2517            combined.matches(owned_view_impl).count(),
2518            1,
2519            "Encodable<Reply> for OwnedView<ReplyView> must appear once: {combined}"
2520        );
2521    }
2522
2523    /// Two service-declaring protos in the same package, plus one in a
2524    /// second package, with a shared dependency proto. Used by the
2525    /// `file_per_package` tests to exercise cross-file inlining and
2526    /// per-package grouping together.
2527    fn file_per_package_fixture() -> Vec<FileDescriptorProto> {
2528        let common = FileDescriptorProto {
2529            name: Some("common.proto".into()),
2530            package: Some("common.v1".into()),
2531            message_type: vec![DescriptorProto {
2532                name: Some("Reply".into()),
2533                ..Default::default()
2534            }],
2535            ..Default::default()
2536        };
2537        // Each service file declares its own request message — proto packages
2538        // can't have duplicate FQNs, so two same-package files with the same
2539        // message name would be an invalid descriptor set (and inlining both
2540        // into one `<dotted.pkg>.rs` under file_per_package would E0428).
2541        let svc = |proto_name: &str, pkg: &str, svc_name: &str, req: &str| FileDescriptorProto {
2542            name: Some(proto_name.into()),
2543            package: Some(pkg.into()),
2544            message_type: vec![DescriptorProto {
2545                name: Some(req.into()),
2546                ..Default::default()
2547            }],
2548            service: vec![ServiceDescriptorProto {
2549                name: Some(svc_name.into()),
2550                method: vec![MethodDescriptorProto {
2551                    name: Some("Call".into()),
2552                    input_type: Some(format!(".{pkg}.{req}")),
2553                    output_type: Some(".common.v1.Reply".into()),
2554                    ..Default::default()
2555                }],
2556                ..Default::default()
2557            }],
2558            ..Default::default()
2559        };
2560        vec![
2561            common,
2562            svc("a/x.proto", "a.v1", "XService", "XReq"),
2563            svc("a/y.proto", "a.v1", "YService", "YReq"),
2564            svc("b/z.proto", "b.v1", "ZService", "ZReq"),
2565        ]
2566    }
2567
2568    #[test]
2569    fn generate_files_file_per_package_inlines_companions() {
2570        let files = file_per_package_fixture();
2571        let mut options = Options::default();
2572        options.buffa.file_per_package = true;
2573
2574        let generated = generate_files(
2575            &files,
2576            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2577            &options,
2578        )
2579        .unwrap();
2580
2581        // No Companion files survive — service stubs are inlined.
2582        assert!(
2583            !generated
2584                .iter()
2585                .any(|f| f.kind == GeneratedFileKind::Companion),
2586            "file_per_package must not emit sibling Companion files"
2587        );
2588        assert!(
2589            !generated.iter().any(|f| f.name.ends_with(".__connect.rs")),
2590            "file_per_package must not emit `<stem>.__connect.rs` files"
2591        );
2592
2593        // Each service-declaring package's PackageMod inlines its services.
2594        let a = generated
2595            .iter()
2596            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "a.v1")
2597            .expect("a.v1 PackageMod must exist");
2598        assert!(
2599            a.content.contains("pub trait XService"),
2600            "a.v1 missing XService"
2601        );
2602        assert!(
2603            a.content.contains("pub trait YService"),
2604            "a.v1 missing YService"
2605        );
2606        assert!(
2607            !a.content.contains("pub trait ZService"),
2608            "a.v1 must not inline ZService"
2609        );
2610        assert!(
2611            !a.content.contains("__connect.rs"),
2612            "a.v1 PackageMod must not include! a connect file: {}",
2613            a.content
2614        );
2615
2616        let b = generated
2617            .iter()
2618            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "b.v1")
2619            .expect("b.v1 PackageMod must exist");
2620        assert!(
2621            b.content.contains("pub trait ZService"),
2622            "b.v1 missing ZService"
2623        );
2624        assert!(
2625            !b.content.contains("pub trait XService"),
2626            "b.v1 must not inline XService"
2627        );
2628
2629        // No PackageMod is emitted for the dependency-only package
2630        // `common.v1` — it is not in `file_to_generate`.
2631        let pkg_mods = generated
2632            .iter()
2633            .filter(|f| f.kind == GeneratedFileKind::PackageMod)
2634            .count();
2635        assert_eq!(
2636            pkg_mods, 2,
2637            "expected exactly two PackageMods: {generated:#?}"
2638        );
2639
2640        // The cross-file Encodable<Reply> dedup must hold under
2641        // file_per_package exactly as it does under the per-proto split:
2642        // one impl pair across the whole batch (else E0119 at consumer
2643        // compile time). All three services return `.common.v1.Reply`.
2644        let combined: String = generated.iter().map(|f| f.content.as_str()).collect();
2645        assert_eq!(
2646            combined
2647                .matches("impl ::connectrpc::Encodable<super::super::common::v1::Reply>")
2648                .count(),
2649            2,
2650            "Encodable<Reply> impls must be deduplicated across packages \
2651             (1 for ReplyView, 1 for OwnedView<ReplyView>): {combined}"
2652        );
2653    }
2654
2655    #[test]
2656    fn generate_services_file_per_package_emits_one_file_per_package() {
2657        let files = file_per_package_fixture();
2658        let mut options = Options::default();
2659        options.buffa.file_per_package = true;
2660        options
2661            .buffa
2662            .extern_paths
2663            .push((".".into(), "crate::proto".into()));
2664
2665        let generated = generate_services(
2666            &files,
2667            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2668            &options,
2669        )
2670        .unwrap();
2671
2672        // Output is exactly one PackageMod per service-declaring package
2673        // with all stubs inlined; no companions, no `<pkg>.mod.rs` stitchers.
2674        assert_eq!(
2675            generated.len(),
2676            2,
2677            "expected exactly two output files: {generated:#?}"
2678        );
2679        assert!(
2680            generated
2681                .iter()
2682                .all(|f| f.kind == GeneratedFileKind::PackageMod),
2683            "all output files must be PackageMod"
2684        );
2685        assert!(
2686            !generated.iter().any(|f| f.name.ends_with(".mod.rs")),
2687            "file_per_package must not emit a separate stitcher"
2688        );
2689        assert!(
2690            !generated.iter().any(|f| f.content.contains("include!")),
2691            "file_per_package output must not include! sibling files"
2692        );
2693
2694        let mut names: Vec<&str> = generated.iter().map(|f| f.name.as_str()).collect();
2695        names.sort_unstable();
2696        assert_eq!(
2697            names,
2698            ["a.v1.rs", "b.v1.rs"],
2699            "filenames must be `<dotted.pkg>.rs` to match buffa's file_per_package convention"
2700        );
2701
2702        let a = generated.iter().find(|f| f.package == "a.v1").unwrap();
2703        assert!(a.content.contains("pub trait XService"));
2704        assert!(a.content.contains("pub trait YService"));
2705        let b = generated.iter().find(|f| f.package == "b.v1").unwrap();
2706        assert!(b.content.contains("pub trait ZService"));
2707        assert!(!b.content.contains("pub trait XService"));
2708    }
2709
2710    #[test]
2711    fn generate_services_file_per_package_default_layout_unchanged() {
2712        // Sanity: when the option is off, the existing per-proto + stitcher
2713        // layout is preserved (regression guard for the new branch).
2714        let files = file_per_package_fixture();
2715        let mut options = Options::default();
2716        options
2717            .buffa
2718            .extern_paths
2719            .push((".".into(), "crate::proto".into()));
2720
2721        let generated = generate_services(
2722            &files,
2723            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2724            &options,
2725        )
2726        .unwrap();
2727
2728        let mut companions: Vec<&str> = generated
2729            .iter()
2730            .filter(|f| f.kind == GeneratedFileKind::Companion)
2731            .map(|f| f.name.as_str())
2732            .collect();
2733        companions.sort_unstable();
2734        assert_eq!(
2735            companions,
2736            ["a.x.__connect.rs", "a.y.__connect.rs", "b.z.__connect.rs"],
2737            "default layout emits one companion per proto"
2738        );
2739        let mut stitchers: Vec<&str> = generated
2740            .iter()
2741            .filter(|f| f.kind == GeneratedFileKind::PackageMod)
2742            .map(|f| f.name.as_str())
2743            .collect();
2744        stitchers.sort_unstable();
2745        assert_eq!(
2746            stitchers,
2747            ["a.v1.mod.rs", "b.v1.mod.rs"],
2748            "default layout emits one stitcher per package"
2749        );
2750        // Each stitcher include!s its package's companions.
2751        let a_stitcher = generated.iter().find(|f| f.name == "a.v1.mod.rs").unwrap();
2752        assert!(
2753            a_stitcher
2754                .content
2755                .contains(r#"include!("a.x.__connect.rs");"#)
2756        );
2757        assert!(
2758            a_stitcher
2759                .content
2760                .contains(r#"include!("a.y.__connect.rs");"#)
2761        );
2762    }
2763
2764    #[test]
2765    fn service_name_with_package() {
2766        let file = minimal_file(
2767            Some("example.v1"),
2768            ".example.v1.PingReq",
2769            ".example.v1.PingResp",
2770            &["PingReq", "PingResp"],
2771        );
2772        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2773        assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
2774    }
2775
2776    #[test]
2777    fn service_name_without_package() {
2778        // Empty package must produce "PingService", not ".PingService".
2779        let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
2780        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2781        assert!(code.contains("\"PingService\""), "got: {code}");
2782        assert!(
2783            !code.contains("\".PingService\""),
2784            "must not have leading dot: {code}"
2785        );
2786    }
2787
2788    #[test]
2789    fn same_package_types_use_bare_names() {
2790        let file = minimal_file(
2791            Some("example.v1"),
2792            ".example.v1.PingReq",
2793            ".example.v1.PingResp",
2794            &["PingReq", "PingResp"],
2795        );
2796        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2797        // Same-package types resolve to bare identifiers.
2798        assert!(code.contains("PingReq"), "input type missing: {code}");
2799        assert!(code.contains("PingResp"), "output type missing: {code}");
2800        // No super:: prefix for same-package types.
2801        assert!(
2802            !code.contains("super :: PingReq"),
2803            "unexpected super: {code}"
2804        );
2805    }
2806
2807    #[test]
2808    fn cross_package_types_use_relative_paths() {
2809        // Service in example.v1 references types from common.v1.
2810        // Must emit a super::-relative path matching buffa's module
2811        // layout, not bare `Shared` (which would fail to compile).
2812        let common = FileDescriptorProto {
2813            name: Some("common.proto".into()),
2814            package: Some("common.v1".into()),
2815            message_type: vec![DescriptorProto {
2816                name: Some("Shared".into()),
2817                ..Default::default()
2818            }],
2819            ..Default::default()
2820        };
2821        let svc = minimal_file(
2822            Some("example.v1"),
2823            ".common.v1.Shared",
2824            ".example.v1.Out",
2825            &["Out"],
2826        );
2827        let code = gen_service(&[common, svc], 1, &[], false).unwrap();
2828
2829        // example.v1 -> super::super -> common::v1::Shared
2830        // (token stream stringifies `::` with spaces, so match loosely)
2831        assert!(
2832            code.contains("super :: super :: common :: v1 :: Shared"),
2833            "cross-package path not emitted: {code}"
2834        );
2835        assert!(
2836            code.contains("super :: super :: common :: v1 :: __buffa :: view :: SharedView"),
2837            "cross-package view path not emitted: {code}"
2838        );
2839    }
2840
2841    #[test]
2842    fn nested_message_view_type_mirrors_owned_module_nesting() {
2843        // Service in example.v1 references Outer.Inner (nested under Outer).
2844        // buffa lays out the view as __buffa::view::outer::InnerView, mirroring
2845        // the owned outer::Inner layout. rust_view_type must insert the
2846        // sentinel at the package boundary, not at the type boundary.
2847        let file = FileDescriptorProto {
2848            name: Some("nested.proto".into()),
2849            package: Some("example.v1".into()),
2850            message_type: vec![
2851                DescriptorProto {
2852                    name: Some("Outer".into()),
2853                    nested_type: vec![DescriptorProto {
2854                        name: Some("Inner".into()),
2855                        ..Default::default()
2856                    }],
2857                    ..Default::default()
2858                },
2859                DescriptorProto {
2860                    name: Some("Out".into()),
2861                    ..Default::default()
2862                },
2863            ],
2864            service: vec![ServiceDescriptorProto {
2865                name: Some("NestedService".into()),
2866                method: vec![MethodDescriptorProto {
2867                    name: Some("Ping".into()),
2868                    input_type: Some(".example.v1.Outer.Inner".into()),
2869                    output_type: Some(".example.v1.Out".into()),
2870                    ..Default::default()
2871                }],
2872                ..Default::default()
2873            }],
2874            ..Default::default()
2875        };
2876        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2877
2878        assert!(
2879            code.contains("__buffa :: view :: outer :: InnerView"),
2880            "nested view path not emitted: {code}"
2881        );
2882        assert!(
2883            code.contains("outer :: Inner"),
2884            "nested owned path not emitted: {code}"
2885        );
2886    }
2887
2888    #[test]
2889    fn wkt_types_use_buffa_types_extern_path() {
2890        // Service referencing google.protobuf.Empty as an input/output
2891        // type. WKT auto-injection maps it to ::buffa_types::..., same
2892        // path buffa-codegen emits for WKT message fields.
2893        let wkt = FileDescriptorProto {
2894            name: Some("google/protobuf/empty.proto".into()),
2895            package: Some("google.protobuf".into()),
2896            message_type: vec![DescriptorProto {
2897                name: Some("Empty".into()),
2898                ..Default::default()
2899            }],
2900            ..Default::default()
2901        };
2902        let svc = minimal_file(
2903            Some("example.v1"),
2904            ".google.protobuf.Empty",
2905            ".example.v1.Out",
2906            &["Out"],
2907        );
2908        let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
2909
2910        assert!(
2911            code.contains(":: buffa_types :: google :: protobuf :: Empty"),
2912            "WKT extern path not emitted: {code}"
2913        );
2914    }
2915
2916    #[test]
2917    fn extern_catchall_uses_absolute_paths() {
2918        let file = minimal_file(
2919            Some("example.v1"),
2920            ".example.v1.PingReq",
2921            ".example.v1.PingResp",
2922            &["PingReq", "PingResp"],
2923        );
2924        let extern_paths = [(".".into(), "crate::proto".into())];
2925        let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
2926        assert!(
2927            code.contains("crate :: proto :: example :: v1 :: PingReq"),
2928            "owned type path missing: {code}"
2929        );
2930        assert!(
2931            code.contains("crate :: proto :: example :: v1 :: __buffa :: view :: PingReqView"),
2932            "view type path missing: {code}"
2933        );
2934    }
2935
2936    #[test]
2937    fn extern_catchall_with_wkt_longest_wins() {
2938        // Auto-injected `.google.protobuf` mapping is more specific than
2939        // the `.` catch-all, so WKTs still route to ::buffa_types.
2940        let wkt = FileDescriptorProto {
2941            name: Some("google/protobuf/empty.proto".into()),
2942            package: Some("google.protobuf".into()),
2943            message_type: vec![DescriptorProto {
2944                name: Some("Empty".into()),
2945                ..Default::default()
2946            }],
2947            ..Default::default()
2948        };
2949        let svc = minimal_file(
2950            Some("example.v1"),
2951            ".google.protobuf.Empty",
2952            ".example.v1.Out",
2953            &["Out"],
2954        );
2955        let extern_paths = [(".".into(), "crate::proto".into())];
2956        let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
2957        assert!(
2958            code.contains(":: buffa_types :: google :: protobuf :: Empty"),
2959            "WKT mapping lost to catch-all: {code}"
2960        );
2961        assert!(
2962            code.contains("crate :: proto :: example :: v1 :: Out"),
2963            "local type not routed through catch-all: {code}"
2964        );
2965    }
2966
2967    #[test]
2968    fn missing_extern_path_errors() {
2969        let file = minimal_file(
2970            Some("example.v1"),
2971            ".example.v1.PingReq",
2972            ".example.v1.PingResp",
2973            &["PingReq", "PingResp"],
2974        );
2975        let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
2976        let msg = err.to_string();
2977        assert!(
2978            msg.contains("extern_path"),
2979            "error message lacks hint: {msg}"
2980        );
2981    }
2982
2983    #[test]
2984    fn keyword_package_escaped() {
2985        // `google.type` -> `google::r#type` via idents::rust_path_to_tokens.
2986        let file = minimal_file(
2987            Some("google.type"),
2988            ".google.type.LatLng",
2989            ".google.type.LatLng",
2990            &["LatLng"],
2991        );
2992        let extern_paths = [(".".into(), "crate::proto".into())];
2993        let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
2994        assert!(
2995            code.contains("crate :: proto :: google :: r#type :: LatLng"),
2996            "keyword segment not escaped: {code}"
2997        );
2998    }
2999
3000    #[test]
3001    fn keyword_method_escaped() {
3002        // `rpc Move(...)` -> snake_case `move` is a Rust keyword; emit `r#move`
3003        // via idents::make_field_ident. Regression for issue #23.
3004        let file = minimal_file_with_method(
3005            Some("example.v1"),
3006            "Move",
3007            ".example.v1.Empty",
3008            ".example.v1.Empty",
3009            &["Empty"],
3010        );
3011        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3012        assert!(
3013            code.contains("fn r#move"),
3014            "keyword method not escaped: {code}"
3015        );
3016        assert!(
3017            code.contains("move_with_options"),
3018            "suffixed variant should not need escaping: {code}"
3019        );
3020        // Doc example should also use the escaped form so the snippet is valid.
3021        assert!(code.contains("client.r#move(request)"));
3022        syn::parse_str::<syn::File>(&code).expect("generated code parses");
3023    }
3024
3025    #[test]
3026    fn path_keyword_method_suffixed() {
3027        // `self`/`super`/`Self`/`crate` cannot be raw identifiers; they are
3028        // suffixed with `_` instead (matching prost convention).
3029        let file = minimal_file_with_method(
3030            Some("example.v1"),
3031            "Self",
3032            ".example.v1.Empty",
3033            ".example.v1.Empty",
3034            &["Empty"],
3035        );
3036        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3037        assert!(
3038            code.contains("fn self_"),
3039            "path-keyword method not suffixed: {code}"
3040        );
3041        // The `_with_options` variant uses the unsuffixed snake name; the
3042        // suffix already de-keywords it, so we get `self_with_options`
3043        // (not `self__with_options`).
3044        assert!(code.contains("self_with_options"));
3045        syn::parse_str::<syn::File>(&code).expect("generated code parses");
3046    }
3047
3048    #[test]
3049    fn service_name_keyword_suffixed() {
3050        // `service Self {}` is accepted by protoc but `Self` is a Rust keyword
3051        // that cannot be a raw ident; the bare trait name is suffixed `Self_`
3052        // while the derived `SelfExt`/`SelfClient`/`SelfServer` are already safe.
3053        let mut file = minimal_file(
3054            Some("example.v1"),
3055            ".example.v1.Empty",
3056            ".example.v1.Empty",
3057            &["Empty"],
3058        );
3059        file.service[0].name = Some("Self".into());
3060        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3061        assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
3062        assert!(code.contains("trait SelfExt"));
3063        assert!(code.contains("struct SelfClient"));
3064        assert!(code.contains("struct SelfServer"));
3065        syn::parse_str::<syn::File>(&code).expect("generated code parses");
3066    }
3067
3068    #[test]
3069    fn method_snake_collision_errors() {
3070        // protoc accepts `GetFoo` and `get_foo` in the same service; both
3071        // snake-case to `get_foo`, which would emit duplicate Rust methods.
3072        let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
3073        let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3074        let msg = err.to_string();
3075        assert!(msg.contains("PingService"), "missing service name: {msg}");
3076        assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
3077        assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
3078        assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
3079    }
3080
3081    #[test]
3082    fn method_with_options_collision_errors() {
3083        // `Ping` generates client method `ping_with_options`; a proto method
3084        // `PingWithOptions` would generate the same base name.
3085        let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
3086        let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3087        let msg = err.to_string();
3088        assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
3089        assert!(
3090            msg.contains("\"PingWithOptions\""),
3091            "missing second method: {msg}"
3092        );
3093        assert!(
3094            msg.contains("`ping_with_options`"),
3095            "missing rust ident: {msg}"
3096        );
3097    }
3098
3099    #[test]
3100    fn distinct_methods_do_not_collide() {
3101        let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
3102        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3103        syn::parse_str::<syn::File>(&code).expect("generated code parses");
3104    }
3105
3106    #[test]
3107    fn options_default_buffa_config() {
3108        let cfg = Options::default().to_buffa_config();
3109        assert!(cfg.generate_json, "connectrpc enables JSON by default");
3110        assert!(cfg.generate_views);
3111        assert!(cfg.emit_register_fn);
3112        assert!(!cfg.strict_utf8_mapping);
3113    }
3114
3115    #[test]
3116    fn options_buffa_passthrough_forces_views() {
3117        let mut opts = Options::default();
3118        opts.buffa.emit_register_fn = false;
3119        opts.buffa.generate_views = false;
3120        let cfg = opts.to_buffa_config();
3121        assert!(!cfg.emit_register_fn);
3122        assert!(cfg.generate_views, "generate_views must be forced on");
3123    }
3124
3125    #[test]
3126    fn generate_files_emit_register_fn_false_suppresses_register_types() {
3127        // Build a file with a single message so buffa would normally emit
3128        // `pub fn register_types(&mut TypeRegistry)` aggregating it.
3129        let file = FileDescriptorProto {
3130            name: Some("ping.proto".into()),
3131            package: Some("example.v1".into()),
3132            message_type: vec![DescriptorProto {
3133                name: Some("PingReq".into()),
3134                ..Default::default()
3135            }],
3136            ..Default::default()
3137        };
3138
3139        // `register_types` is emitted into the per-package stitcher, so
3140        // locate the PackageMod output and check that one.
3141        let stitcher = |files: &[GeneratedFile]| {
3142            files
3143                .iter()
3144                .find(|f| f.kind == GeneratedFileKind::PackageMod)
3145                .expect("PackageMod file emitted")
3146                .content
3147                .clone()
3148        };
3149
3150        let with_fn = generate_files(
3151            std::slice::from_ref(&file),
3152            &["ping.proto".into()],
3153            &Options::default(),
3154        )
3155        .unwrap();
3156        let mod_rs = stitcher(&with_fn);
3157        assert!(
3158            mod_rs.contains("fn register_types"),
3159            "expected register_types in default output: {mod_rs}"
3160        );
3161
3162        let mut opts = Options::default();
3163        opts.buffa.emit_register_fn = false;
3164        let without_fn =
3165            generate_files(std::slice::from_ref(&file), &["ping.proto".into()], &opts).unwrap();
3166        let mod_rs = stitcher(&without_fn);
3167        assert!(
3168            !mod_rs.contains("fn register_types"),
3169            "register_types should be suppressed: {mod_rs}"
3170        );
3171    }
3172
3173    #[test]
3174    fn plugin_no_register_fn_parses() {
3175        let request = CodeGeneratorRequest {
3176            parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
3177            file_to_generate: vec![],
3178            proto_file: vec![],
3179            ..Default::default()
3180        };
3181        // Plugin path emits services only, so we can't observe the buffa
3182        // config directly — just make sure the option parses without error.
3183        generate(&request).expect("no_register_fn should be a recognized plugin option");
3184    }
3185
3186    #[test]
3187    fn plugin_file_per_package_collapses_output() {
3188        // End-to-end through the protoc entry point: one `<dotted.pkg>.rs`
3189        // per package, no `<stem>.__connect.rs`, no `<pkg>.mod.rs`.
3190        let request = CodeGeneratorRequest {
3191            parameter: Some("buffa_module=crate::proto,file_per_package".into()),
3192            file_to_generate: vec!["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3193            proto_file: file_per_package_fixture(),
3194            ..Default::default()
3195        };
3196        let response = generate(&request).expect("file_per_package should parse and generate");
3197        let mut names: Vec<&str> = response
3198            .file
3199            .iter()
3200            .filter_map(|f| f.name.as_deref())
3201            .collect();
3202        names.sort_unstable();
3203        assert_eq!(
3204            names,
3205            ["a.v1.rs", "b.v1.rs"],
3206            "expected one file per package: {names:?}"
3207        );
3208        for f in &response.file {
3209            let content = f.content.as_deref().unwrap_or_default();
3210            assert!(
3211                !content.contains("include!"),
3212                "file_per_package output must be self-contained: {content}"
3213            );
3214        }
3215    }
3216
3217    #[test]
3218    fn no_top_level_use_statements_in_generated_code() {
3219        // When multiple service files are `include!`d into the same module,
3220        // top-level `use` statements cause E0252 (duplicate imports). Verify
3221        // the generated code uses fully qualified paths instead.
3222        let file = minimal_file(
3223            Some("example.v1"),
3224            ".example.v1.PingReq",
3225            ".example.v1.PingResp",
3226            &["PingReq", "PingResp"],
3227        );
3228        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3229        let formatted = format_token_stream(&code.parse::<TokenStream>().unwrap()).unwrap();
3230        assert_no_top_level_use(&formatted, "generated code");
3231    }
3232
3233    #[test]
3234    fn multi_service_include_no_e0252() {
3235        // Simulate `buffa-packaging` including two service files into one
3236        // module. Both files must parse together without duplicate imports.
3237        let file_a = {
3238            let method = MethodDescriptorProto {
3239                name: Some("Ping".into()),
3240                input_type: Some(".svc.v1.PingReq".into()),
3241                output_type: Some(".svc.v1.PingResp".into()),
3242                ..Default::default()
3243            };
3244            let service = ServiceDescriptorProto {
3245                name: Some("Alpha".into()),
3246                method: vec![method],
3247                ..Default::default()
3248            };
3249            FileDescriptorProto {
3250                name: Some("alpha.proto".into()),
3251                package: Some("svc.v1".into()),
3252                service: vec![service],
3253                message_type: vec![
3254                    DescriptorProto {
3255                        name: Some("PingReq".into()),
3256                        ..Default::default()
3257                    },
3258                    DescriptorProto {
3259                        name: Some("PingResp".into()),
3260                        ..Default::default()
3261                    },
3262                ],
3263                ..Default::default()
3264            }
3265        };
3266        let file_b = {
3267            let method = MethodDescriptorProto {
3268                name: Some("Pong".into()),
3269                input_type: Some(".svc.v1.PongReq".into()),
3270                output_type: Some(".svc.v1.PongResp".into()),
3271                ..Default::default()
3272            };
3273            let service = ServiceDescriptorProto {
3274                name: Some("Beta".into()),
3275                method: vec![method],
3276                ..Default::default()
3277            };
3278            FileDescriptorProto {
3279                name: Some("beta.proto".into()),
3280                package: Some("svc.v1".into()),
3281                service: vec![service],
3282                message_type: vec![
3283                    DescriptorProto {
3284                        name: Some("PongReq".into()),
3285                        ..Default::default()
3286                    },
3287                    DescriptorProto {
3288                        name: Some("PongResp".into()),
3289                        ..Default::default()
3290                    },
3291                ],
3292                ..Default::default()
3293            }
3294        };
3295
3296        let files = vec![file_a, file_b];
3297        let config = buffa_codegen::CodeGenConfig::default();
3298        let targets = vec!["alpha.proto".to_string(), "beta.proto".to_string()];
3299        let resolver = TypeResolver::new(&files, &targets, &config, false);
3300
3301        let mut batch = BatchState {
3302            colliding_aliases: collect_alias_collisions(&files, &targets),
3303            ..BatchState::default()
3304        };
3305        let code_a = generate_connect_services(&files[0], &resolver, &mut batch).unwrap();
3306        let code_b = generate_connect_services(&files[1], &resolver, &mut batch).unwrap();
3307
3308        let formatted_a = format_token_stream(&code_a).unwrap();
3309        let formatted_b = format_token_stream(&code_b).unwrap();
3310
3311        // Each file independently must parse.
3312        syn::parse_str::<syn::File>(&formatted_a).expect("service A should parse independently");
3313        syn::parse_str::<syn::File>(&formatted_b).expect("service B should parse independently");
3314
3315        // Both files combined into one module must also parse (the E0252 scenario).
3316        let combined = format!("{formatted_a}\n{formatted_b}");
3317        syn::parse_str::<syn::File>(&combined)
3318            .expect("combined services should parse without E0252");
3319
3320        // No top-level `use` in either file.
3321        assert_no_top_level_use(&formatted_a, "service A");
3322        assert_no_top_level_use(&formatted_b, "service B");
3323    }
3324
3325    /// `generate_spec_consts` emits one `pub const … : Spec` per method,
3326    /// named `{SERVICE}_{METHOD}_SPEC`, with the right `StreamType`,
3327    /// `IdempotencyLevel`, and procedure path.
3328    #[test]
3329    fn generate_spec_consts_per_method() {
3330        use buffa_codegen::generated::descriptor::MethodOptions;
3331
3332        let m = |name: &str, cs: bool, ss: bool, idem: Option<IdempotencyLevel>| {
3333            MethodDescriptorProto {
3334                name: Some(name.into()),
3335                input_type: Some(".pkg.Req".into()),
3336                output_type: Some(".pkg.Resp".into()),
3337                client_streaming: Some(cs),
3338                server_streaming: Some(ss),
3339                options: MethodOptions {
3340                    idempotency_level: idem,
3341                    ..Default::default()
3342                }
3343                .into(),
3344                ..Default::default()
3345            }
3346        };
3347        let service = ServiceDescriptorProto {
3348            name: Some("EchoService".into()),
3349            method: vec![
3350                m("Say", false, false, Some(IdempotencyLevel::NO_SIDE_EFFECTS)),
3351                m("Subscribe", false, true, Some(IdempotencyLevel::IDEMPOTENT)),
3352                m("Upload", true, false, None),
3353                m("Chat", true, true, None),
3354            ],
3355            ..Default::default()
3356        };
3357
3358        // The const names follow `{SERVICE}_{METHOD}_SPEC`.
3359        assert_eq!(
3360            method_spec_const_ident(&service, "Say").to_string(),
3361            "ECHO_SERVICE_SAY_SPEC"
3362        );
3363
3364        let consts = generate_spec_consts("pkg.EchoService", &service);
3365        assert_eq!(consts.len(), 4, "one const per method");
3366
3367        let render = |ts: &TokenStream| {
3368            let file = syn::parse2::<syn::File>(ts.clone()).expect("const should parse");
3369            prettyplease::unparse(&file)
3370        };
3371        let say = render(&consts[0]);
3372        assert!(say.contains("pub const ECHO_SERVICE_SAY_SPEC"), "{say}");
3373        assert!(say.contains(r#""/pkg.EchoService/Say""#), "{say}");
3374        assert!(say.contains("StreamType::Unary"), "{say}");
3375        assert!(say.contains("IdempotencyLevel::NoSideEffects"), "{say}");
3376
3377        let subscribe = render(&consts[1]);
3378        assert!(
3379            subscribe.contains("StreamType::ServerStream"),
3380            "{subscribe}"
3381        );
3382        assert!(
3383            subscribe.contains("IdempotencyLevel::Idempotent"),
3384            "{subscribe}"
3385        );
3386
3387        let upload = render(&consts[2]);
3388        assert!(upload.contains("StreamType::ClientStream"), "{upload}");
3389        assert!(upload.contains("IdempotencyLevel::Unknown"), "{upload}");
3390
3391        let chat = render(&consts[3]);
3392        assert!(chat.contains("StreamType::BidiStream"), "{chat}");
3393    }
3394}