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::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::GeneratedFile;
29pub use buffa_codegen::generated::descriptor;
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 { field: value, ..Options::default() }`.
41#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct Options {
44    /// Emit `Vec<u8>`/`&[u8]` for proto string fields with
45    /// `utf8_validation = NONE` instead of `String`/`&str`. See
46    /// `buffa_codegen::CodeGenConfig::strict_utf8_mapping`.
47    pub strict_utf8_mapping: bool,
48    /// Emit `serde::Serialize` / `serde::Deserialize` derives and the proto3
49    /// JSON mapping helpers on generated message types. Required for the
50    /// Connect protocol's JSON codec; disable only if you're targeting
51    /// binary-only clients.
52    pub generate_json: bool,
53    /// Map protobuf package prefixes to Rust module paths for message types.
54    ///
55    /// Each entry is `(proto_prefix, rust_path)`, e.g.
56    /// `(".", "crate::proto")` routes every type through `crate::proto::...`.
57    /// More-specific prefixes win via longest-prefix-match, and the WKT
58    /// mapping (`.google.protobuf` -> `::buffa_types::...`) is auto-injected.
59    ///
60    /// Used by [`generate_services`] to bake absolute paths into service
61    /// stubs so they compile independently of co-generated message types.
62    /// Unused by [`generate_files`] (the unified `super::`-relative path).
63    pub extern_paths: Vec<(String, String)>,
64    /// Emit the per-file `register_types(&mut TypeRegistry)` function that
65    /// aggregates every message in the file. Default `true`. See
66    /// `buffa_codegen::CodeGenConfig::emit_register_fn`.
67    ///
68    /// Set to `false` when multiple generated files are `include!`d into
69    /// the same module — the identically-named `register_types` fns would
70    /// otherwise collide. The per-message `__*_JSON_ANY` consts are still
71    /// emitted; only the aggregating function is suppressed.
72    pub emit_register_fn: bool,
73}
74
75impl Default for Options {
76    fn default() -> Self {
77        Self {
78            strict_utf8_mapping: false,
79            generate_json: true,
80            extern_paths: Vec::new(),
81            emit_register_fn: true,
82        }
83    }
84}
85
86impl Options {
87    fn to_buffa_config(&self) -> buffa_codegen::CodeGenConfig {
88        let mut config = buffa_codegen::CodeGenConfig::default();
89        config.generate_views = true;
90        config.generate_json = self.generate_json;
91        config.strict_utf8_mapping = self.strict_utf8_mapping;
92        config.extern_paths.clone_from(&self.extern_paths);
93        config.emit_register_fn = self.emit_register_fn;
94        config
95    }
96}
97
98/// Emit one [`GeneratedFile`] per proto file in `file_to_generate` that
99/// declares at least one `service`. Files with no services produce no output.
100fn emit_service_files(
101    proto_file: &[FileDescriptorProto],
102    file_to_generate: &[String],
103    resolver: &TypeResolver<'_>,
104) -> Result<Vec<GeneratedFile>> {
105    let mut out = Vec::new();
106    for file_name in file_to_generate {
107        let file_desc = proto_file
108            .iter()
109            .find(|f| f.name.as_deref() == Some(file_name.as_str()));
110
111        if let Some(file) = file_desc
112            && !file.service.is_empty()
113        {
114            let service_tokens = generate_connect_services(file, resolver)?;
115            let service_code = format_token_stream(&service_tokens)?;
116            out.push(GeneratedFile {
117                name: buffa_codegen::proto_path_to_rust_module(file_name),
118                content: service_code,
119            });
120        }
121    }
122    Ok(out)
123}
124
125/// Generate ConnectRPC service bindings + buffa message types from proto
126/// descriptors, appended into a single per-file output.
127///
128/// Returns one `GeneratedFile` per proto file in `file_to_generate`. Does
129/// **not** emit a `mod.rs` — callers assemble the module tree themselves
130/// (typically `connectrpc-build` via an `include!`-based file).
131///
132/// This is the **unified** path: service stubs reference message types via
133/// `super::`-relative paths, so both must live in the same module tree.
134/// [`Options::extern_paths`] is ignored.
135///
136/// # Errors
137///
138/// Returns an error if buffa-codegen fails (e.g. unsupported proto
139/// feature) or if the generated service binding Rust does not parse
140/// under `syn` (indicates a bug in this crate).
141pub fn generate_files(
142    proto_file: &[FileDescriptorProto],
143    file_to_generate: &[String],
144    options: &Options,
145) -> Result<Vec<GeneratedFile>> {
146    let config = options.to_buffa_config();
147
148    let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
149        .map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
150
151    let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
152    let service_files = emit_service_files(proto_file, file_to_generate, &resolver)?;
153
154    // Append each service file's content to the matching message file.
155    for svc in service_files {
156        if let Some(out) = files.iter_mut().find(|g| g.name == svc.name) {
157            out.content.push('\n');
158            out.content.push_str(&svc.content);
159        }
160    }
161
162    Ok(files)
163}
164
165/// Generate **only** ConnectRPC service bindings from proto descriptors.
166///
167/// Returns one `GeneratedFile` per proto file in `file_to_generate` that
168/// declares at least one `service`. No message types, no `mod.rs`.
169///
170/// This is the **split** path: service stubs reference message types via
171/// absolute Rust paths derived from [`Options::extern_paths`]. Callers must
172/// set at least a `.` catch-all entry (e.g. `(".", "crate::proto")`) so
173/// every type resolves; the auto-injected WKT mapping still takes priority
174/// via longest-prefix-match. The generated code compiles standalone as long
175/// as the extern paths point at a buffa-generated module tree.
176///
177/// # Errors
178///
179/// Errors if any method input/output type is not covered by an extern_path
180/// mapping, or is absent from `proto_file` (missing import).
181pub fn generate_services(
182    proto_file: &[FileDescriptorProto],
183    file_to_generate: &[String],
184    options: &Options,
185) -> Result<Vec<GeneratedFile>> {
186    let config = options.to_buffa_config();
187    let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
188    emit_service_files(proto_file, file_to_generate, &resolver)
189}
190
191/// Generate a `CodeGeneratorResponse` from a protoc `CodeGeneratorRequest`.
192///
193/// This is the entry point for the protoc plugin (`protoc-gen-connect-rust`).
194/// It parses the comma-separated `request.parameter` into [`Options`] and
195/// delegates to [`generate_services`] — service stubs only. Callers must
196/// run `protoc-gen-buffa` (or equivalent) separately for message types.
197///
198/// # Recognized options
199///
200/// - `buffa_module=<rust_path>` — where you mounted the buffa-generated
201///   module tree (e.g. `buffa_module=crate::proto`). Shorthand for
202///   `extern_path=.=<rust_path>`. This is the option most local users want.
203/// - `extern_path=<proto>=<rust>` — map a specific proto package prefix
204///   to a Rust module path. Repeatable; longest-prefix-match wins.
205///   `extern_path=.=<path>` is the catch-all (equivalent to `buffa_module`).
206///   At least one catch-all mapping is required so every type resolves.
207/// - `strict_utf8_mapping` — see [`Options::strict_utf8_mapping`].
208/// - `no_json` — disable `serde` derives on generated message types.
209///   Ignored in this plugin (no message types emitted); accepted for
210///   compatibility with the unified path.
211/// - `no_register_fn` — suppress the per-file
212///   `register_types(&mut TypeRegistry)` aggregator. See
213///   [`Options::emit_register_fn`]. Ignored in this plugin (no message
214///   types emitted); accepted for compatibility with the unified path.
215pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
216    let mut options = Options::default();
217
218    if let Some(ref param) = request.parameter {
219        for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
220            if let Some(value) = opt.strip_prefix("buffa_module=") {
221                let rust = value.trim();
222                if rust.is_empty() {
223                    anyhow::bail!(
224                        "buffa_module requires a non-empty path, \
225                         e.g. buffa_module=crate::proto"
226                    );
227                }
228                options.extern_paths.push((".".into(), rust.to_string()));
229            } else if let Some(value) = opt.strip_prefix("extern_path=") {
230                // value is "<proto_path>=<rust_path>"
231                let (proto, rust) = value.split_once('=').ok_or_else(|| {
232                    anyhow::anyhow!(
233                        "invalid extern_path format {value:?}, expected \
234                         extern_path=.proto.pkg=::rust::path"
235                    )
236                })?;
237                let proto = proto.trim();
238                let rust = rust.trim();
239                if proto.is_empty() || rust.is_empty() {
240                    anyhow::bail!(
241                        "invalid extern_path format {value:?}, expected \
242                         extern_path=.proto.pkg=::rust::path (both sides non-empty)"
243                    );
244                }
245                let mut proto = proto.to_string();
246                if !proto.starts_with('.') {
247                    proto.insert(0, '.');
248                }
249                options.extern_paths.push((proto, rust.to_string()));
250            } else {
251                match opt {
252                    "strict_utf8_mapping" => options.strict_utf8_mapping = true,
253                    "no_json" => options.generate_json = false,
254                    "no_register_fn" => options.emit_register_fn = false,
255                    _ => {
256                        return Err(anyhow::anyhow!(
257                            "unknown plugin option: {opt:?}. Supported: \
258                             buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
259                             strict_utf8_mapping, no_json, no_register_fn"
260                        ));
261                    }
262                }
263            }
264        }
265    }
266
267    let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
268
269    let files: Vec<CodeGeneratorResponseFile> = generated
270        .into_iter()
271        .map(|g| CodeGeneratorResponseFile {
272            name: Some(g.name),
273            content: Some(g.content),
274            ..Default::default()
275        })
276        .collect();
277
278    Ok(CodeGeneratorResponse {
279        supported_features: Some(feature_flags()),
280        minimum_edition: Some(EDITION_2023),
281        maximum_edition: Some(EDITION_2023),
282        file: files,
283        ..Default::default()
284    })
285}
286
287/// Feature flags we support (bitmask). See
288/// `google.protobuf.compiler.CodeGeneratorResponse.Feature`.
289fn feature_flags() -> u64 {
290    const FEATURE_PROTO3_OPTIONAL: u64 = 1;
291    const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
292    FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
293}
294
295/// Edition 2023 numeric value. buffa-codegen handles proto2/proto3/edition-2023;
296/// we declare 2023 as both min and max.
297const EDITION_2023: i32 = 1000;
298
299/// Format a TokenStream into a Rust source string via prettyplease.
300fn format_token_stream(tokens: &TokenStream) -> Result<String> {
301    let file = syn::parse2::<syn::File>(tokens.clone())
302        .map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
303    Ok(prettyplease::unparse(&file))
304}
305
306/// Emit `#[doc = " line"]` attributes for each line of `text`.
307///
308/// prettyplease renders `#[doc = "X"]` as `///X` verbatim (no space inserted);
309/// to get `/// X` the string must already start with a space. This helper
310/// prefixes each line with a space so the unparsed output matches hand-written
311/// doc comment style.
312///
313/// Leaves blank lines as-is (→ `///`) so paragraph breaks render correctly.
314fn doc_attrs(text: &str) -> TokenStream {
315    let lines: Vec<String> = text
316        .lines()
317        .map(|l| {
318            if l.is_empty() {
319                String::new()
320            } else {
321                format!(" {l}")
322            }
323        })
324        .collect();
325    quote! { #(#[doc = #lines])* }
326}
327
328// ---------------------------------------------------------------------------
329// Type path resolution
330// ---------------------------------------------------------------------------
331
332/// Resolves fully-qualified protobuf type names to Rust type-path tokens
333/// relative to the current file's package module.
334///
335/// Wraps [`buffa_codegen::context::CodeGenContext`] via `for_generate()` so
336/// service method input/output types resolve to the same paths buffa-codegen
337/// emits for message fields — including cross-package (`super::foo::Bar`),
338/// WKT extern paths (`::buffa_types::google::protobuf::Empty`), and nested
339/// types (`outer::Inner`). Zero drift with buffa's own generation.
340struct TypeResolver<'a> {
341    ctx: buffa_codegen::context::CodeGenContext<'a>,
342    /// When true, every resolved path must be absolute (`::foo` or
343    /// `crate::foo`). Paths that would resolve to `super::`-relative or
344    /// bare-ident forms produce an error instead. Used by
345    /// [`generate_services`] to enforce that service stubs reference
346    /// message types via `extern_path` only.
347    require_extern: bool,
348}
349
350impl<'a> TypeResolver<'a> {
351    fn new(
352        proto_file: &'a [FileDescriptorProto],
353        file_to_generate: &[String],
354        config: &'a buffa_codegen::CodeGenConfig,
355        require_extern: bool,
356    ) -> Self {
357        Self {
358            ctx: buffa_codegen::context::CodeGenContext::for_generate(
359                proto_file,
360                file_to_generate,
361                config,
362            ),
363            require_extern,
364        }
365    }
366
367    /// Resolve a proto FQN (e.g. `.google.protobuf.Empty`) to a Rust type-path
368    /// string relative to `current_package`.
369    ///
370    /// In `require_extern` mode, errors if the path is not absolute or the
371    /// type is absent from the descriptor set. Otherwise falls back to the
372    /// bare type name for unknown types (rustc will point at the use site).
373    fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
374        match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
375            Some(path) => {
376                if self.require_extern && !path.starts_with("::") && !path.starts_with("crate::") {
377                    anyhow::bail!(
378                        "type {proto_fqn} is not covered by any extern_path mapping. \
379                         Add extern_path=.=<your_buffa_module> (e.g. \
380                         extern_path=.=crate::proto) to the plugin opts."
381                    );
382                }
383                Ok(path)
384            }
385            None if self.require_extern => anyhow::bail!(
386                "type {proto_fqn} not found in descriptor set (missing proto import?)"
387            ),
388            None => Ok(bare_type_name(proto_fqn).to_string()),
389        }
390    }
391
392    /// Resolve a proto FQN to Rust type-path tokens.
393    fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
394        let path = self.resolve_path(proto_fqn, current_package)?;
395        Ok(rust_path_to_tokens(&path))
396    }
397
398    /// Like [`rust_type`] but appends `View` to the last path segment, e.g.
399    /// `super::foo::Bar` -> `super::foo::BarView`.
400    fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
401        let path = self.resolve_path(proto_fqn, current_package)?;
402        Ok(rust_path_to_tokens(&format!("{path}View")))
403    }
404}
405
406/// Last segment of a proto FQN, e.g. `.google.protobuf.Empty` → `"Empty"`.
407/// Fallback for types absent from the resolver context.
408fn bare_type_name(proto_fqn: &str) -> &str {
409    proto_fqn
410        .strip_prefix('.')
411        .unwrap_or(proto_fqn)
412        .rsplit('.')
413        .next()
414        .unwrap_or(proto_fqn)
415}
416
417// ---------------------------------------------------------------------------
418// ConnectRPC service code generation
419// ---------------------------------------------------------------------------
420
421/// Generate ConnectRPC service bindings for a file.
422fn generate_connect_services(
423    file: &FileDescriptorProto,
424    resolver: &TypeResolver<'_>,
425) -> Result<TokenStream> {
426    let mut tokens = TokenStream::new();
427
428    // All crate-level imports use `::connectrpc` (absolute path) so that
429    // proto packages named `connectrpc.*` (e.g. `connectrpc.conformance.v1`)
430    // don't shadow the crate in generated module scopes.
431    let imports = quote! {
432        use std::future::Future;
433        use std::pin::Pin;
434        use std::sync::Arc;
435
436        use ::connectrpc::{Context, ConnectError, Router, Dispatcher, view_handler_fn, view_streaming_handler_fn, view_client_streaming_handler_fn, view_bidi_streaming_handler_fn};
437        use ::connectrpc::dispatcher::codegen as __crpc_codegen;
438        use ::connectrpc::CodecFormat as __CodecFormat;
439        use buffa::bytes::Bytes as __Bytes;
440        use ::connectrpc::client::{ClientConfig, ClientTransport, CallOptions, call_unary, call_server_stream, call_client_stream, call_bidi_stream};
441        use futures::Stream;
442        use buffa::Message;
443        use buffa::view::OwnedView;
444    };
445    tokens.extend(imports);
446
447    for service in &file.service {
448        tokens.extend(generate_service(file, service, resolver)?);
449    }
450
451    Ok(tokens)
452}
453
454/// Generate code for a single service.
455/// Reject RPC method sets whose generated Rust identifiers collide.
456///
457/// Each proto method `Foo` produces both `foo` and `foo_with_options` on the
458/// client. Two methods that normalize to the same snake_case name (e.g.
459/// `GetFoo` and `get_foo`), or one whose snake form equals another's
460/// `_with_options` form, would emit duplicate definitions and fail to
461/// compile with an error pointing at generated code rather than the proto.
462fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
463    let mut seen: HashMap<String, String> = HashMap::new();
464    for m in &service.method {
465        let proto_name = m.name.as_deref().unwrap_or("");
466        let snake = proto_name.to_snake_case();
467        let with_opts = format!("{snake}_with_options");
468        for ident in [snake.as_str(), with_opts.as_str()] {
469            if let Some(prev) = seen.get(ident) {
470                anyhow::bail!(
471                    "service {service_name}: RPC methods {prev:?} and {proto_name:?} \
472                     both generate Rust identifier `{ident}`; rename one in the proto"
473                );
474            }
475        }
476        seen.insert(snake, proto_name.to_string());
477        seen.insert(with_opts, proto_name.to_string());
478    }
479    Ok(())
480}
481
482fn generate_service(
483    file: &FileDescriptorProto,
484    service: &ServiceDescriptorProto,
485    resolver: &TypeResolver<'_>,
486) -> Result<TokenStream> {
487    let package = file.package.as_deref().unwrap_or("");
488    let service_name = service.name.as_deref().unwrap_or("");
489    check_method_collisions(service_name, service)?;
490    // Empty package is valid proto; the fully-qualified service name is just
491    // `ServiceName`, not `.ServiceName` (which would break interop).
492    let full_service_name = if package.is_empty() {
493        service_name.to_string()
494    } else {
495        format!("{package}.{service_name}")
496    };
497    let service_upper = service_name.to_upper_camel_case();
498    // `Self` is the only PascalCase Rust keyword, and cannot be a raw ident;
499    // suffix it so `service Self {}` (accepted by protoc) generates a valid
500    // trait. The suffixed derivatives below are already keyword-safe.
501    let trait_name = if service_upper == "Self" {
502        format_ident!("Self_")
503    } else {
504        format_ident!("{}", service_upper)
505    };
506    let ext_trait_name = format_ident!("{}Ext", service_upper);
507    let client_name = format_ident!("{}Client", service_upper);
508    let server_name = format_ident!("{}Server", service_upper);
509    let service_name_const = format_ident!(
510        "{}_SERVICE_NAME",
511        service_name.to_snake_case().to_uppercase()
512    );
513
514    // Get service documentation and append async impl guidance
515    let service_doc = get_service_comment(file, service).unwrap_or_default();
516    let base_doc = if service_doc.is_empty() {
517        format!("Server trait for {service_name}.")
518    } else {
519        service_doc
520    };
521    let full_doc = format!(
522        "{base_doc}\n\n\
523         # Implementing handlers\n\n\
524         Handlers receive requests as `OwnedView<FooView<'static>>`, which gives\n\
525         zero-copy borrowed access to fields (e.g. `request.name` is a `&str`\n\
526         into the decoded buffer). The view can be held across `.await` points.\n\n\
527         Implement methods with plain `async fn`; the returned future satisfies\n\
528         the `Send` bound automatically. See the\n\
529         [buffa user guide](https://github.com/anthropics/buffa/blob/main/docs/guide.md#ownedview-in-async-trait-implementations)\n\
530         for zero-copy access patterns and when `to_owned_message()` is needed."
531    );
532    let service_doc_tokens = doc_attrs(&full_doc);
533
534    // Generate trait methods
535    let trait_methods: Vec<TokenStream> = service
536        .method
537        .iter()
538        .map(|m| generate_trait_method(file, service, m, resolver, package))
539        .collect::<Result<Vec<_>>>()?;
540
541    // Generate route registrations for extension trait
542    let route_registrations: Vec<TokenStream> = service
543        .method
544        .iter()
545        .map(|m| {
546            let method_name = m.name.as_deref().unwrap_or("");
547            let method_snake = make_field_ident(&method_name.to_snake_case());
548
549            let client_streaming = m.client_streaming.unwrap_or(false);
550            let server_streaming = m.server_streaming.unwrap_or(false);
551
552            if server_streaming && !client_streaming {
553                // Server streaming method
554                quote! {
555                    .route_view_server_stream(
556                        #service_name_const,
557                        #method_name,
558                        view_streaming_handler_fn({
559                            let svc = Arc::clone(&self);
560                            move |ctx, req| {
561                                let svc = Arc::clone(&svc);
562                                async move { svc.#method_snake(ctx, req).await }
563                            }
564                        }),
565                    )
566                }
567            } else if client_streaming && !server_streaming {
568                // Client streaming method
569                quote! {
570                    .route_view_client_stream(
571                        #service_name_const,
572                        #method_name,
573                        view_client_streaming_handler_fn({
574                            let svc = Arc::clone(&self);
575                            move |ctx, req| {
576                                let svc = Arc::clone(&svc);
577                                async move { svc.#method_snake(ctx, req).await }
578                            }
579                        }),
580                    )
581                }
582            } else if client_streaming && server_streaming {
583                // Bidi streaming method
584                quote! {
585                    .route_view_bidi_stream(
586                        #service_name_const,
587                        #method_name,
588                        view_bidi_streaming_handler_fn({
589                            let svc = Arc::clone(&self);
590                            move |ctx, req| {
591                                let svc = Arc::clone(&svc);
592                                async move { svc.#method_snake(ctx, req).await }
593                            }
594                        }),
595                    )
596                }
597            } else {
598                // Unary method
599                let is_idempotent = m
600                    .options
601                    .idempotency_level
602                    .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
603                    .unwrap_or(false);
604
605                let route_method = if is_idempotent {
606                    quote! { route_view_idempotent }
607                } else {
608                    quote! { route_view }
609                };
610
611                quote! {
612                    .#route_method(
613                        #service_name_const,
614                        #method_name,
615                        {
616                            let svc = Arc::clone(&self);
617                            view_handler_fn(move |ctx, req| {
618                                let svc = Arc::clone(&svc);
619                                async move { svc.#method_snake(ctx, req).await }
620                            })
621                        },
622                    )
623                }
624            }
625        })
626        .collect();
627
628    // Generate client methods
629    let client_methods: Vec<TokenStream> = service
630        .method
631        .iter()
632        .map(|m| generate_client_method(&full_service_name, m, resolver, package))
633        .collect::<Result<Vec<_>>>()?;
634
635    // Generate monomorphic FooServiceServer<T> dispatcher.
636    let service_server = generate_service_server(
637        &full_service_name,
638        &trait_name,
639        &server_name,
640        service,
641        resolver,
642        package,
643    )?;
644
645    // Example method name for client doc
646    let example_method = service
647        .method
648        .first()
649        .and_then(|m| m.name.as_deref())
650        .map(|n| make_field_ident(&n.to_snake_case()).to_string())
651        .unwrap_or_else(|| "method".to_string());
652
653    // Build client doc comment with interpolated example method
654    let client_name_str = client_name.to_string();
655    let client_doc = format!(
656        r#"Client for this service.
657
658Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
659`Http2Connection` — it has honest `poll_ready` and composes with
660`tower::balance` for multi-connection load balancing. For **Connect
661over HTTP/1.1** (or unknown protocol), use `HttpClient`.
662
663# Example (gRPC / HTTP/2)
664
665```rust,ignore
666use connectrpc::client::{{Http2Connection, ClientConfig}};
667use connectrpc::Protocol;
668
669let uri: http::Uri = "http://localhost:8080".parse()?;
670let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
671let config = ClientConfig::new(uri).protocol(Protocol::Grpc);
672
673let client = {client_name_str}::new(conn, config);
674let response = client.{example_method}(request).await?;
675```
676
677# Example (Connect / HTTP/1.1 or ALPN)
678
679```rust,ignore
680use connectrpc::client::{{HttpClient, ClientConfig}};
681
682let http = HttpClient::plaintext();  // cleartext http:// only
683let config = ClientConfig::new("http://localhost:8080".parse()?);
684
685let client = {client_name_str}::new(http, config);
686let response = client.{example_method}(request).await?;
687```
688
689# Working with the response
690
691Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
692The `OwnedView` derefs to the view, so field access is zero-copy:
693
694```rust,ignore
695let resp = client.{example_method}(request).await?.into_view();
696let name: &str = resp.name;  // borrow into the response buffer
697```
698
699If you need the owned struct (e.g. to store or pass by value), use
700[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
701
702```rust,ignore
703let owned = client.{example_method}(request).await?.into_owned();
704```"#
705    );
706    let client_doc_tokens = doc_attrs(&client_doc);
707
708    Ok(quote! {
709        // -----------------------------------------------------------------------------
710        // #service_name
711        // -----------------------------------------------------------------------------
712
713        /// Full service name for this service.
714        pub const #service_name_const: &str = #full_service_name;
715
716        #service_doc_tokens
717        #[allow(clippy::type_complexity)]
718        pub trait #trait_name: Send + Sync + 'static {
719            #(#trait_methods)*
720        }
721
722        /// Extension trait for registering a service implementation with a Router.
723        ///
724        /// This trait is automatically implemented for all types that implement the service trait.
725        ///
726        /// # Example
727        ///
728        /// ```rust,ignore
729        /// use std::sync::Arc;
730        ///
731        /// let service = Arc::new(MyServiceImpl);
732        /// let router = service.register(Router::new());
733        /// ```
734        pub trait #ext_trait_name: #trait_name {
735            /// Register this service implementation with a Router.
736            ///
737            /// Takes ownership of the `Arc<Self>` and returns a new Router with
738            /// this service's methods registered.
739            fn register(self: Arc<Self>, router: Router) -> Router;
740        }
741
742        impl<S: #trait_name> #ext_trait_name for S {
743            fn register(self: Arc<Self>, router: Router) -> Router {
744                router
745                    #(#route_registrations)*
746            }
747        }
748
749        #service_server
750
751        #client_doc_tokens
752        #[derive(Clone)]
753        pub struct #client_name<T> {
754            transport: T,
755            config: ClientConfig,
756        }
757
758        impl<T> #client_name<T>
759        where
760            T: ClientTransport,
761            <T::ResponseBody as http_body::Body>::Error: std::fmt::Display,
762        {
763            /// Create a new client with the given transport and configuration.
764            pub fn new(transport: T, config: ClientConfig) -> Self {
765                Self { transport, config }
766            }
767
768            /// Get the client configuration.
769            pub fn config(&self) -> &ClientConfig {
770                &self.config
771            }
772
773            /// Get a mutable reference to the client configuration.
774            pub fn config_mut(&mut self) -> &mut ClientConfig {
775                &mut self.config
776            }
777
778            #(#client_methods)*
779        }
780    })
781}
782
783/// Generate a monomorphic `FooServiceServer<T>` struct and its `Dispatcher` impl.
784///
785/// This is the fast-path alternative to `FooServiceExt::register(Router)`: instead
786/// of type-erasing each method behind `Arc<dyn ErasedHandler>` and looking them up
787/// in a `HashMap`, this struct dispatches via a compile-time `match` on method name
788/// with no trait objects or hash lookups in the hot path.
789fn generate_service_server(
790    full_service_name: &str,
791    trait_name: &proc_macro2::Ident,
792    server_name: &proc_macro2::Ident,
793    service: &ServiceDescriptorProto,
794    resolver: &TypeResolver<'_>,
795    package: &str,
796) -> Result<TokenStream> {
797    // Path prefix matched by `dispatch` / `call_*`: "pkg.Service/"
798    let path_prefix = format!("{full_service_name}/");
799
800    // Per-method match arms for `lookup(path)`.
801    let lookup_arms: Vec<TokenStream> = service
802        .method
803        .iter()
804        .map(|m| {
805            let method_name = m.name.as_deref().unwrap_or("");
806            let client_streaming = m.client_streaming.unwrap_or(false);
807            let server_streaming = m.server_streaming.unwrap_or(false);
808            let is_idempotent = m
809                .options
810                .idempotency_level
811                .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
812                .unwrap_or(false);
813
814            let desc = if client_streaming && server_streaming {
815                quote! { __crpc_codegen::MethodDescriptor::bidi_streaming() }
816            } else if client_streaming {
817                quote! { __crpc_codegen::MethodDescriptor::client_streaming() }
818            } else if server_streaming {
819                quote! { __crpc_codegen::MethodDescriptor::server_streaming() }
820            } else {
821                quote! { __crpc_codegen::MethodDescriptor::unary(#is_idempotent) }
822            };
823            quote! { #method_name => Some(#desc), }
824        })
825        .collect();
826
827    // Per-kind match arms for the four `call_*` methods.
828    // Each `call_*` only includes arms for methods of the matching kind; other
829    // paths fall through to `unimplemented_*` (the caller checked `lookup()`
830    // first, so this is a defensive-only branch).
831    let mut call_unary_arms: Vec<TokenStream> = Vec::new();
832    let mut call_ss_arms: Vec<TokenStream> = Vec::new();
833    let mut call_cs_arms: Vec<TokenStream> = Vec::new();
834    let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
835
836    for m in &service.method {
837        let method_name = m.name.as_deref().unwrap_or("");
838        let method_snake = make_field_ident(&method_name.to_snake_case());
839        let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
840        let cs = m.client_streaming.unwrap_or(false);
841        let ss = m.server_streaming.unwrap_or(false);
842
843        if cs && ss {
844            // Bidi streaming
845            call_bidi_arms.push(quote! {
846                #method_name => {
847                    let svc = Arc::clone(&self.inner);
848                    Box::pin(async move {
849                        let req_stream = __crpc_codegen::decode_view_request_stream::<#input_view>(requests, format);
850                        let (resp_stream, ctx) = svc.#method_snake(ctx, req_stream).await?;
851                        Ok((__crpc_codegen::encode_response_stream(resp_stream, format), ctx))
852                    })
853                }
854            });
855        } else if cs {
856            // Client streaming
857            call_cs_arms.push(quote! {
858                #method_name => {
859                    let svc = Arc::clone(&self.inner);
860                    Box::pin(async move {
861                        let req_stream = __crpc_codegen::decode_view_request_stream::<#input_view>(requests, format);
862                        let (res, ctx) = svc.#method_snake(ctx, req_stream).await?;
863                        let bytes = __crpc_codegen::encode_response(&res, format)?;
864                        Ok((bytes, ctx))
865                    })
866                }
867            });
868        } else if ss {
869            // Server streaming
870            call_ss_arms.push(quote! {
871                #method_name => {
872                    let svc = Arc::clone(&self.inner);
873                    Box::pin(async move {
874                        let req = __crpc_codegen::decode_request_view::<#input_view>(request, format)?;
875                        let (resp_stream, ctx) = svc.#method_snake(ctx, req).await?;
876                        Ok((__crpc_codegen::encode_response_stream(resp_stream, format), ctx))
877                    })
878                }
879            });
880        } else {
881            // Unary
882            call_unary_arms.push(quote! {
883                #method_name => {
884                    let svc = Arc::clone(&self.inner);
885                    Box::pin(async move {
886                        let req = __crpc_codegen::decode_request_view::<#input_view>(request, format)?;
887                        let (res, ctx) = svc.#method_snake(ctx, req).await?;
888                        let bytes = __crpc_codegen::encode_response(&res, format)?;
889                        Ok((bytes, ctx))
890                    })
891                }
892            });
893        }
894    }
895
896    let server_doc = format!(
897        "Monomorphic dispatcher for `{trait_name}`.\n\n\
898         Unlike `.register(Router)` which type-erases each method into an \
899         `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
900         via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
901         # Example\n\n\
902         ```rust,ignore\n\
903         use connectrpc::ConnectRpcService;\n\n\
904         let server = {server_name}::new(MyImpl);\n\
905         let service = ConnectRpcService::new(server);\n\
906         // hand `service` to axum/hyper as a fallback_service\n\
907         ```"
908    );
909    let server_doc_tokens = doc_attrs(&server_doc);
910
911    Ok(quote! {
912        #server_doc_tokens
913        pub struct #server_name<T> {
914            inner: Arc<T>,
915        }
916
917        impl<T: #trait_name> #server_name<T> {
918            /// Wrap a service implementation in a monomorphic dispatcher.
919            pub fn new(service: T) -> Self {
920                Self { inner: Arc::new(service) }
921            }
922
923            /// Wrap an already-`Arc`'d service implementation.
924            pub fn from_arc(inner: Arc<T>) -> Self {
925                Self { inner }
926            }
927        }
928
929        impl<T> Clone for #server_name<T> {
930            fn clone(&self) -> Self {
931                Self { inner: Arc::clone(&self.inner) }
932            }
933        }
934
935        impl<T: #trait_name> Dispatcher for #server_name<T> {
936            #[inline]
937            fn lookup(&self, path: &str) -> Option<__crpc_codegen::MethodDescriptor> {
938                let method = path.strip_prefix(#path_prefix)?;
939                match method {
940                    #(#lookup_arms)*
941                    _ => None,
942                }
943            }
944
945            fn call_unary(
946                &self,
947                path: &str,
948                ctx: Context,
949                request: __Bytes,
950                format: __CodecFormat,
951            ) -> __crpc_codegen::UnaryResult {
952                let Some(method) = path.strip_prefix(#path_prefix) else {
953                    return __crpc_codegen::unimplemented_unary(path);
954                };
955                // Suppress unused warnings when this service has no unary methods.
956                let _ = (&ctx, &request, &format);
957                match method {
958                    #(#call_unary_arms)*
959                    _ => __crpc_codegen::unimplemented_unary(path),
960                }
961            }
962
963            fn call_server_streaming(
964                &self,
965                path: &str,
966                ctx: Context,
967                request: __Bytes,
968                format: __CodecFormat,
969            ) -> __crpc_codegen::StreamingResult {
970                let Some(method) = path.strip_prefix(#path_prefix) else {
971                    return __crpc_codegen::unimplemented_streaming(path);
972                };
973                let _ = (&ctx, &request, &format);
974                match method {
975                    #(#call_ss_arms)*
976                    _ => __crpc_codegen::unimplemented_streaming(path),
977                }
978            }
979
980            fn call_client_streaming(
981                &self,
982                path: &str,
983                ctx: Context,
984                requests: __crpc_codegen::RequestStream,
985                format: __CodecFormat,
986            ) -> __crpc_codegen::UnaryResult {
987                let Some(method) = path.strip_prefix(#path_prefix) else {
988                    return __crpc_codegen::unimplemented_unary(path);
989                };
990                let _ = (&ctx, &requests, &format);
991                match method {
992                    #(#call_cs_arms)*
993                    _ => __crpc_codegen::unimplemented_unary(path),
994                }
995            }
996
997            fn call_bidi_streaming(
998                &self,
999                path: &str,
1000                ctx: Context,
1001                requests: __crpc_codegen::RequestStream,
1002                format: __CodecFormat,
1003            ) -> __crpc_codegen::StreamingResult {
1004                let Some(method) = path.strip_prefix(#path_prefix) else {
1005                    return __crpc_codegen::unimplemented_streaming(path);
1006                };
1007                let _ = (&ctx, &requests, &format);
1008                match method {
1009                    #(#call_bidi_arms)*
1010                    _ => __crpc_codegen::unimplemented_streaming(path),
1011                }
1012            }
1013        }
1014    })
1015}
1016
1017/// Generate documentation comment tokens.
1018fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
1019    let comment = if doc.is_empty() { default } else { doc };
1020    doc_attrs(comment)
1021}
1022
1023/// Generate a trait method for a service.
1024fn generate_trait_method(
1025    file: &FileDescriptorProto,
1026    service: &ServiceDescriptorProto,
1027    method: &MethodDescriptorProto,
1028    resolver: &TypeResolver<'_>,
1029    package: &str,
1030) -> Result<TokenStream> {
1031    let method_name = method.name.as_deref().unwrap_or("");
1032    let method_snake = make_field_ident(&method_name.to_snake_case());
1033    let input_view_type =
1034        resolver.rust_view_type(method.input_type.as_deref().unwrap_or(""), package)?;
1035    let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
1036
1037    // Get method documentation
1038    let method_doc = get_method_comment(file, service, method).unwrap_or_default();
1039    let method_doc_tokens =
1040        generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
1041
1042    // Check for streaming
1043    let client_streaming = method.client_streaming.unwrap_or(false);
1044    let server_streaming = method.server_streaming.unwrap_or(false);
1045
1046    if server_streaming && !client_streaming {
1047        // Server streaming method
1048        Ok(quote! {
1049            #method_doc_tokens
1050            fn #method_snake(
1051                &self,
1052                ctx: Context,
1053                request: OwnedView<#input_view_type<'static>>,
1054            ) -> impl Future<Output = Result<(Pin<Box<dyn Stream<Item = Result<#output_type, ConnectError>> + Send>>, Context), ConnectError>> + Send;
1055        })
1056    } else if client_streaming && !server_streaming {
1057        // Client streaming method
1058        Ok(quote! {
1059            #method_doc_tokens
1060            fn #method_snake(
1061                &self,
1062                ctx: Context,
1063                requests: Pin<Box<dyn Stream<Item = Result<OwnedView<#input_view_type<'static>>, ConnectError>> + Send>>,
1064            ) -> impl Future<Output = Result<(#output_type, Context), ConnectError>> + Send;
1065        })
1066    } else if client_streaming && server_streaming {
1067        // Bidi streaming method
1068        Ok(quote! {
1069            #method_doc_tokens
1070            fn #method_snake(
1071                &self,
1072                ctx: Context,
1073                requests: Pin<Box<dyn Stream<Item = Result<OwnedView<#input_view_type<'static>>, ConnectError>> + Send>>,
1074            ) -> impl Future<Output = Result<(Pin<Box<dyn Stream<Item = Result<#output_type, ConnectError>> + Send>>, Context), ConnectError>> + Send;
1075        })
1076    } else {
1077        // Unary method
1078        Ok(quote! {
1079            #method_doc_tokens
1080            fn #method_snake(
1081                &self,
1082                ctx: Context,
1083                request: OwnedView<#input_view_type<'static>>,
1084            ) -> impl Future<Output = Result<(#output_type, Context), ConnectError>> + Send;
1085        })
1086    }
1087}
1088
1089/// Generate client method(s) for a service RPC.
1090///
1091/// Emits two methods per RPC:
1092///   - `<method_snake>(&self, ...)` — no-options convenience, delegates to `_with_options`
1093///   - `<method_snake>_with_options(&self, ..., options: CallOptions)` — explicit options
1094///
1095/// This gives callers an ergonomic default while still surfacing per-call
1096/// control. The library's `effective_options()` merges options over
1097/// ClientConfig defaults, so the no-options variant still picks up any
1098/// client-wide defaults the user configured.
1099fn generate_client_method(
1100    full_service_name: &str,
1101    method: &MethodDescriptorProto,
1102    resolver: &TypeResolver<'_>,
1103    package: &str,
1104) -> Result<TokenStream> {
1105    let method_name = method.name.as_deref().unwrap_or("");
1106    let method_snake = make_field_ident(&method_name.to_snake_case());
1107    let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
1108    let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
1109    let output_view_type =
1110        resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
1111
1112    let client_streaming = method.client_streaming.unwrap_or(false);
1113    let server_streaming = method.server_streaming.unwrap_or(false);
1114
1115    let doc = format!(
1116        " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
1117    );
1118    let doc_opts = format!(
1119        " Call the {method_name} RPC with explicit per-call options. \
1120         Options override [`ClientConfig`] defaults."
1121    );
1122
1123    // Return type is protocol-specific. Compute once.
1124    let ret_ty: TokenStream;
1125    let call_body: TokenStream;
1126    let short_args: TokenStream; // args to the no-opts convenience method
1127    let opts_args: TokenStream; // args to the _with_options method
1128    let short_delegate_args: TokenStream; // how short delegates to opts
1129
1130    if client_streaming && !server_streaming {
1131        // Client-stream
1132        ret_ty = quote! {
1133            Result<
1134                ::connectrpc::client::UnaryResponse<OwnedView<#output_view_type<'static>>>,
1135                ConnectError,
1136            >
1137        };
1138        call_body = quote! {
1139            call_client_stream(
1140                &self.transport, &self.config,
1141                #full_service_name, #method_name,
1142                requests, options,
1143            ).await
1144        };
1145        short_args = quote! { requests: impl IntoIterator<Item = #input_type> };
1146        opts_args =
1147            quote! { requests: impl IntoIterator<Item = #input_type>, options: CallOptions };
1148        short_delegate_args = quote! { requests, CallOptions::default() };
1149    } else if client_streaming && server_streaming {
1150        // Bidi
1151        ret_ty = quote! {
1152            Result<
1153                ::connectrpc::client::BidiStream<
1154                    T::ResponseBody, #input_type, #output_view_type<'static>
1155                >,
1156                ConnectError,
1157            >
1158        };
1159        call_body = quote! {
1160            call_bidi_stream(
1161                &self.transport, &self.config,
1162                #full_service_name, #method_name, options,
1163            ).await
1164        };
1165        short_args = quote! {};
1166        opts_args = quote! { options: CallOptions };
1167        short_delegate_args = quote! { CallOptions::default() };
1168    } else if server_streaming {
1169        // Server-stream
1170        ret_ty = quote! {
1171            Result<
1172                ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
1173                ConnectError,
1174            >
1175        };
1176        call_body = quote! {
1177            call_server_stream(
1178                &self.transport, &self.config,
1179                #full_service_name, #method_name,
1180                request, options,
1181            ).await
1182        };
1183        short_args = quote! { request: #input_type };
1184        opts_args = quote! { request: #input_type, options: CallOptions };
1185        short_delegate_args = quote! { request, CallOptions::default() };
1186    } else {
1187        // Unary
1188        ret_ty = quote! {
1189            Result<
1190                ::connectrpc::client::UnaryResponse<OwnedView<#output_view_type<'static>>>,
1191                ConnectError,
1192            >
1193        };
1194        call_body = quote! {
1195            call_unary(
1196                &self.transport, &self.config,
1197                #full_service_name, #method_name,
1198                request, options,
1199            ).await
1200        };
1201        short_args = quote! { request: #input_type };
1202        opts_args = quote! { request: #input_type, options: CallOptions };
1203        short_delegate_args = quote! { request, CallOptions::default() };
1204    }
1205
1206    Ok(quote! {
1207        #[doc = #doc]
1208        pub async fn #method_snake(&self, #short_args) -> #ret_ty {
1209            self.#method_with_opts(#short_delegate_args).await
1210        }
1211
1212        #[doc = #doc_opts]
1213        pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
1214            #call_body
1215        }
1216    })
1217}
1218
1219/// Get the documentation comment for a service.
1220fn get_service_comment(
1221    file: &FileDescriptorProto,
1222    service: &ServiceDescriptorProto,
1223) -> Option<String> {
1224    // MessageField derefs to default when unset; default has empty location vec
1225    let source_info: &SourceCodeInfo = &file.source_code_info;
1226
1227    // Find service index
1228    let service_index = file.service.iter().position(|s| s.name == service.name)?;
1229
1230    // Path for service: [6, service_index]
1231    // 6 = service field number in FileDescriptorProto
1232    let target_path = vec![6, service_index as i32];
1233
1234    find_comment(source_info, &target_path)
1235}
1236
1237/// Get the documentation comment for a method.
1238fn get_method_comment(
1239    file: &FileDescriptorProto,
1240    service: &ServiceDescriptorProto,
1241    method: &MethodDescriptorProto,
1242) -> Option<String> {
1243    let source_info: &SourceCodeInfo = &file.source_code_info;
1244
1245    // Find service and method indices, matching on the parent service name
1246    // to avoid ambiguity when multiple services have methods with the same name.
1247    let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
1248        if s.name != service.name {
1249            return None;
1250        }
1251        s.method
1252            .iter()
1253            .position(|m| m.name == method.name)
1254            .map(|mi| (si, mi))
1255    })?;
1256
1257    // Path for method: [6, service_index, 2, method_index]
1258    // 6 = service field number in FileDescriptorProto
1259    // 2 = method field number in ServiceDescriptorProto
1260    let target_path = vec![6, service_index as i32, 2, method_index as i32];
1261
1262    find_comment(source_info, &target_path)
1263}
1264
1265/// Find a comment in source code info for the given path.
1266fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
1267    for location in &source_info.location {
1268        if location.path == target_path {
1269            let comment = location
1270                .leading_comments
1271                .as_ref()
1272                .or(location.trailing_comments.as_ref())?;
1273
1274            // Trim each line; blank lines are dropped (protoc's convention
1275            // uses a leading space we don't need here — `doc_attrs` adds
1276            // its own uniform leading space for prettyplease rendering).
1277            let cleaned: String = comment
1278                .lines()
1279                .map(|line| line.trim())
1280                .filter(|line| !line.is_empty())
1281                .collect::<Vec<_>>()
1282                .join("\n");
1283
1284            if !cleaned.is_empty() {
1285                return Some(cleaned);
1286            }
1287        }
1288    }
1289    None
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295    use buffa_codegen::generated::descriptor::DescriptorProto;
1296
1297    #[test]
1298    fn doc_attrs_prefixes_space_for_prettyplease() {
1299        // prettyplease emits `#[doc = "X"]` as `///X` verbatim. We prefix
1300        // each non-blank line with a space so the output is `/// X`.
1301        let ts = quote! {
1302            #[allow(dead_code)]
1303            mod m {}
1304        };
1305        let doc = doc_attrs("Hello.\n\nSecond paragraph.");
1306        let combined = quote! { #doc #ts };
1307        let file = syn::parse2::<syn::File>(combined).unwrap();
1308        let out = prettyplease::unparse(&file);
1309        // Each non-blank line should have a space after ///.
1310        assert!(out.contains("/// Hello."), "got: {out}");
1311        assert!(out.contains("/// Second paragraph."), "got: {out}");
1312        // Blank line becomes bare /// (paragraph break).
1313        assert!(out.contains("///\n"), "got: {out}");
1314        // Should NOT contain ///H (no space) or ///  H (double space).
1315        assert!(!out.contains("///Hello"), "got: {out}");
1316        assert!(!out.contains("///  Hello"), "got: {out}");
1317    }
1318
1319    /// Build a minimal proto file with one message type and one service method.
1320    /// The service method's input/output types are fully-qualified proto names
1321    /// (e.g. `.example.v1.PingReq` or `.google.protobuf.Empty`) so the resolver
1322    /// can look them up.
1323    fn minimal_file(
1324        package: Option<&str>,
1325        input_type: &str,
1326        output_type: &str,
1327        local_messages: &[&str],
1328    ) -> FileDescriptorProto {
1329        minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
1330    }
1331
1332    /// Like [`minimal_file`] but with a custom RPC method name, for testing
1333    /// keyword collisions and other name-derived behaviour.
1334    fn minimal_file_with_method(
1335        package: Option<&str>,
1336        method_name: &str,
1337        input_type: &str,
1338        output_type: &str,
1339        local_messages: &[&str],
1340    ) -> FileDescriptorProto {
1341        let method = MethodDescriptorProto {
1342            name: Some(method_name.into()),
1343            input_type: Some(input_type.into()),
1344            output_type: Some(output_type.into()),
1345            ..Default::default()
1346        };
1347        let service = ServiceDescriptorProto {
1348            name: Some("PingService".into()),
1349            method: vec![method],
1350            ..Default::default()
1351        };
1352        FileDescriptorProto {
1353            name: Some("ping.proto".into()),
1354            package: package.map(|p| p.into()),
1355            service: vec![service],
1356            message_type: local_messages
1357                .iter()
1358                .map(|name| DescriptorProto {
1359                    name: Some((*name).into()),
1360                    ..Default::default()
1361                })
1362                .collect(),
1363            ..Default::default()
1364        }
1365    }
1366
1367    /// Build a minimal proto file with one service holding the given method
1368    /// names, all typed `Empty` -> `Empty`. Used for collision tests where
1369    /// the method *names* are what's under test.
1370    fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
1371        let methods = method_names
1372            .iter()
1373            .map(|n| MethodDescriptorProto {
1374                name: Some((*n).into()),
1375                input_type: Some(format!(".{package}.Empty")),
1376                output_type: Some(format!(".{package}.Empty")),
1377                ..Default::default()
1378            })
1379            .collect();
1380        let service = ServiceDescriptorProto {
1381            name: Some("PingService".into()),
1382            method: methods,
1383            ..Default::default()
1384        };
1385        FileDescriptorProto {
1386            name: Some("ping.proto".into()),
1387            package: Some(package.into()),
1388            service: vec![service],
1389            message_type: vec![DescriptorProto {
1390                name: Some("Empty".into()),
1391                ..Default::default()
1392            }],
1393            ..Default::default()
1394        }
1395    }
1396
1397    /// Generate service code for `files[target_idx]`. All files are visible
1398    /// to the resolver (as transitive deps via `--include_imports`), but
1399    /// only the target is in `file_to_generate` — mirroring real protoc use.
1400    ///
1401    /// `extern_paths` is wired into `CodeGenConfig.extern_paths` (which
1402    /// feeds the resolver's type_map via `effective_extern_paths`).
1403    /// `require_extern` selects unified (`false`, super::-relative) vs
1404    /// split (`true`, absolute-only) mode.
1405    fn gen_service(
1406        files: &[FileDescriptorProto],
1407        target_idx: usize,
1408        extern_paths: &[(String, String)],
1409        require_extern: bool,
1410    ) -> Result<String> {
1411        let mut config = buffa_codegen::CodeGenConfig::default();
1412        config.extern_paths = extern_paths.to_vec();
1413        let target_name = files[target_idx]
1414            .name
1415            .clone()
1416            .into_iter()
1417            .collect::<Vec<_>>();
1418        let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
1419        let file = &files[target_idx];
1420        let service = &file.service[0];
1421        Ok(generate_service(file, service, &resolver)?.to_string())
1422    }
1423
1424    #[test]
1425    fn service_name_with_package() {
1426        let file = minimal_file(
1427            Some("example.v1"),
1428            ".example.v1.PingReq",
1429            ".example.v1.PingResp",
1430            &["PingReq", "PingResp"],
1431        );
1432        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1433        assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
1434    }
1435
1436    #[test]
1437    fn service_name_without_package() {
1438        // Empty package must produce "PingService", not ".PingService".
1439        let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
1440        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1441        assert!(code.contains("\"PingService\""), "got: {code}");
1442        assert!(
1443            !code.contains("\".PingService\""),
1444            "must not have leading dot: {code}"
1445        );
1446    }
1447
1448    #[test]
1449    fn same_package_types_use_bare_names() {
1450        let file = minimal_file(
1451            Some("example.v1"),
1452            ".example.v1.PingReq",
1453            ".example.v1.PingResp",
1454            &["PingReq", "PingResp"],
1455        );
1456        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1457        // Same-package types resolve to bare identifiers.
1458        assert!(code.contains("PingReq"), "input type missing: {code}");
1459        assert!(code.contains("PingResp"), "output type missing: {code}");
1460        // No super:: prefix for same-package types.
1461        assert!(
1462            !code.contains("super :: PingReq"),
1463            "unexpected super: {code}"
1464        );
1465    }
1466
1467    #[test]
1468    fn cross_package_types_use_relative_paths() {
1469        // Service in example.v1 references types from common.v1.
1470        // Must emit a super::-relative path matching buffa's module
1471        // layout, not bare `Shared` (which would fail to compile).
1472        let common = FileDescriptorProto {
1473            name: Some("common.proto".into()),
1474            package: Some("common.v1".into()),
1475            message_type: vec![DescriptorProto {
1476                name: Some("Shared".into()),
1477                ..Default::default()
1478            }],
1479            ..Default::default()
1480        };
1481        let svc = minimal_file(
1482            Some("example.v1"),
1483            ".common.v1.Shared",
1484            ".example.v1.Out",
1485            &["Out"],
1486        );
1487        let code = gen_service(&[common, svc], 1, &[], false).unwrap();
1488
1489        // example.v1 -> super::super -> common::v1::Shared
1490        // (token stream stringifies `::` with spaces, so match loosely)
1491        assert!(
1492            code.contains("super :: super :: common :: v1 :: Shared"),
1493            "cross-package path not emitted: {code}"
1494        );
1495        assert!(
1496            code.contains("super :: super :: common :: v1 :: SharedView"),
1497            "cross-package view path not emitted: {code}"
1498        );
1499    }
1500
1501    #[test]
1502    fn wkt_types_use_buffa_types_extern_path() {
1503        // Service referencing google.protobuf.Empty as an input/output
1504        // type. WKT auto-injection maps it to ::buffa_types::..., same
1505        // path buffa-codegen emits for WKT message fields.
1506        let wkt = FileDescriptorProto {
1507            name: Some("google/protobuf/empty.proto".into()),
1508            package: Some("google.protobuf".into()),
1509            message_type: vec![DescriptorProto {
1510                name: Some("Empty".into()),
1511                ..Default::default()
1512            }],
1513            ..Default::default()
1514        };
1515        let svc = minimal_file(
1516            Some("example.v1"),
1517            ".google.protobuf.Empty",
1518            ".example.v1.Out",
1519            &["Out"],
1520        );
1521        let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
1522
1523        assert!(
1524            code.contains(":: buffa_types :: google :: protobuf :: Empty"),
1525            "WKT extern path not emitted: {code}"
1526        );
1527    }
1528
1529    #[test]
1530    fn extern_catchall_uses_absolute_paths() {
1531        let file = minimal_file(
1532            Some("example.v1"),
1533            ".example.v1.PingReq",
1534            ".example.v1.PingResp",
1535            &["PingReq", "PingResp"],
1536        );
1537        let extern_paths = [(".".into(), "crate::proto".into())];
1538        let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
1539        assert!(
1540            code.contains("crate :: proto :: example :: v1 :: PingReq"),
1541            "owned type path missing: {code}"
1542        );
1543        assert!(
1544            code.contains("crate :: proto :: example :: v1 :: PingReqView"),
1545            "view type path missing: {code}"
1546        );
1547    }
1548
1549    #[test]
1550    fn extern_catchall_with_wkt_longest_wins() {
1551        // Auto-injected `.google.protobuf` mapping is more specific than
1552        // the `.` catch-all, so WKTs still route to ::buffa_types.
1553        let wkt = FileDescriptorProto {
1554            name: Some("google/protobuf/empty.proto".into()),
1555            package: Some("google.protobuf".into()),
1556            message_type: vec![DescriptorProto {
1557                name: Some("Empty".into()),
1558                ..Default::default()
1559            }],
1560            ..Default::default()
1561        };
1562        let svc = minimal_file(
1563            Some("example.v1"),
1564            ".google.protobuf.Empty",
1565            ".example.v1.Out",
1566            &["Out"],
1567        );
1568        let extern_paths = [(".".into(), "crate::proto".into())];
1569        let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
1570        assert!(
1571            code.contains(":: buffa_types :: google :: protobuf :: Empty"),
1572            "WKT mapping lost to catch-all: {code}"
1573        );
1574        assert!(
1575            code.contains("crate :: proto :: example :: v1 :: Out"),
1576            "local type not routed through catch-all: {code}"
1577        );
1578    }
1579
1580    #[test]
1581    fn missing_extern_path_errors() {
1582        let file = minimal_file(
1583            Some("example.v1"),
1584            ".example.v1.PingReq",
1585            ".example.v1.PingResp",
1586            &["PingReq", "PingResp"],
1587        );
1588        let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
1589        let msg = err.to_string();
1590        assert!(
1591            msg.contains("extern_path"),
1592            "error message lacks hint: {msg}"
1593        );
1594    }
1595
1596    #[test]
1597    fn keyword_package_escaped() {
1598        // `google.type` -> `google::r#type` via idents::rust_path_to_tokens.
1599        let file = minimal_file(
1600            Some("google.type"),
1601            ".google.type.LatLng",
1602            ".google.type.LatLng",
1603            &["LatLng"],
1604        );
1605        let extern_paths = [(".".into(), "crate::proto".into())];
1606        let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
1607        assert!(
1608            code.contains("crate :: proto :: google :: r#type :: LatLng"),
1609            "keyword segment not escaped: {code}"
1610        );
1611    }
1612
1613    #[test]
1614    fn keyword_method_escaped() {
1615        // `rpc Move(...)` -> snake_case `move` is a Rust keyword; emit `r#move`
1616        // via idents::make_field_ident. Regression for issue #23.
1617        let file = minimal_file_with_method(
1618            Some("example.v1"),
1619            "Move",
1620            ".example.v1.Empty",
1621            ".example.v1.Empty",
1622            &["Empty"],
1623        );
1624        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1625        assert!(
1626            code.contains("fn r#move"),
1627            "keyword method not escaped: {code}"
1628        );
1629        assert!(
1630            code.contains("move_with_options"),
1631            "suffixed variant should not need escaping: {code}"
1632        );
1633        // Doc example should also use the escaped form so the snippet is valid.
1634        assert!(code.contains("client.r#move(request)"));
1635        syn::parse_str::<syn::File>(&code).expect("generated code parses");
1636    }
1637
1638    #[test]
1639    fn path_keyword_method_suffixed() {
1640        // `self`/`super`/`Self`/`crate` cannot be raw identifiers; they are
1641        // suffixed with `_` instead (matching prost convention).
1642        let file = minimal_file_with_method(
1643            Some("example.v1"),
1644            "Self",
1645            ".example.v1.Empty",
1646            ".example.v1.Empty",
1647            &["Empty"],
1648        );
1649        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1650        assert!(
1651            code.contains("fn self_"),
1652            "path-keyword method not suffixed: {code}"
1653        );
1654        // The `_with_options` variant uses the unsuffixed snake name; the
1655        // suffix already de-keywords it, so we get `self_with_options`
1656        // (not `self__with_options`).
1657        assert!(code.contains("self_with_options"));
1658        syn::parse_str::<syn::File>(&code).expect("generated code parses");
1659    }
1660
1661    #[test]
1662    fn service_name_keyword_suffixed() {
1663        // `service Self {}` is accepted by protoc but `Self` is a Rust keyword
1664        // that cannot be a raw ident; the bare trait name is suffixed `Self_`
1665        // while the derived `SelfExt`/`SelfClient`/`SelfServer` are already safe.
1666        let mut file = minimal_file(
1667            Some("example.v1"),
1668            ".example.v1.Empty",
1669            ".example.v1.Empty",
1670            &["Empty"],
1671        );
1672        file.service[0].name = Some("Self".into());
1673        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1674        assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
1675        assert!(code.contains("trait SelfExt"));
1676        assert!(code.contains("struct SelfClient"));
1677        assert!(code.contains("struct SelfServer"));
1678        syn::parse_str::<syn::File>(&code).expect("generated code parses");
1679    }
1680
1681    #[test]
1682    fn method_snake_collision_errors() {
1683        // protoc accepts `GetFoo` and `get_foo` in the same service; both
1684        // snake-case to `get_foo`, which would emit duplicate Rust methods.
1685        let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
1686        let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
1687        let msg = err.to_string();
1688        assert!(msg.contains("PingService"), "missing service name: {msg}");
1689        assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
1690        assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
1691        assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
1692    }
1693
1694    #[test]
1695    fn method_with_options_collision_errors() {
1696        // `Ping` generates client method `ping_with_options`; a proto method
1697        // `PingWithOptions` would generate the same base name.
1698        let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
1699        let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
1700        let msg = err.to_string();
1701        assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
1702        assert!(
1703            msg.contains("\"PingWithOptions\""),
1704            "missing second method: {msg}"
1705        );
1706        assert!(
1707            msg.contains("`ping_with_options`"),
1708            "missing rust ident: {msg}"
1709        );
1710    }
1711
1712    #[test]
1713    fn distinct_methods_do_not_collide() {
1714        let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
1715        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1716        syn::parse_str::<syn::File>(&code).expect("generated code parses");
1717    }
1718
1719    #[test]
1720    fn options_default_emits_register_fn() {
1721        let opts = Options::default();
1722        assert!(opts.emit_register_fn);
1723        let cfg = opts.to_buffa_config();
1724        assert!(cfg.emit_register_fn);
1725    }
1726
1727    #[test]
1728    fn options_emit_register_fn_false_disables_buffa_register_fn() {
1729        let opts = Options {
1730            emit_register_fn: false,
1731            ..Options::default()
1732        };
1733        let cfg = opts.to_buffa_config();
1734        assert!(!cfg.emit_register_fn);
1735    }
1736
1737    #[test]
1738    fn generate_files_emit_register_fn_false_suppresses_register_types() {
1739        // Build a file with a single message so buffa would normally emit
1740        // `pub fn register_types(&mut TypeRegistry)` aggregating it.
1741        let file = FileDescriptorProto {
1742            name: Some("ping.proto".into()),
1743            package: Some("example.v1".into()),
1744            message_type: vec![DescriptorProto {
1745                name: Some("PingReq".into()),
1746                ..Default::default()
1747            }],
1748            ..Default::default()
1749        };
1750
1751        let with_fn = generate_files(
1752            std::slice::from_ref(&file),
1753            &["ping.proto".into()],
1754            &Options::default(),
1755        )
1756        .unwrap();
1757        assert_eq!(with_fn.len(), 1);
1758        assert!(
1759            with_fn[0].content.contains("fn register_types"),
1760            "expected register_types in default output: {}",
1761            with_fn[0].content
1762        );
1763
1764        let without_fn = generate_files(
1765            std::slice::from_ref(&file),
1766            &["ping.proto".into()],
1767            &Options {
1768                emit_register_fn: false,
1769                ..Options::default()
1770            },
1771        )
1772        .unwrap();
1773        assert_eq!(without_fn.len(), 1);
1774        assert!(
1775            !without_fn[0].content.contains("fn register_types"),
1776            "register_types should be suppressed: {}",
1777            without_fn[0].content
1778        );
1779    }
1780
1781    #[test]
1782    fn plugin_no_register_fn_parses() {
1783        let request = CodeGeneratorRequest {
1784            parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
1785            file_to_generate: vec![],
1786            proto_file: vec![],
1787            ..Default::default()
1788        };
1789        // Plugin path emits services only, so we can't observe the buffa
1790        // config directly — just make sure the option parses without error.
1791        generate(&request).expect("no_register_fn should be a recognized plugin option");
1792    }
1793}