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