1use 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#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct Options {
44 pub strict_utf8_mapping: bool,
48 pub generate_json: bool,
53 pub extern_paths: Vec<(String, String)>,
64 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
98fn 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
125pub 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 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
165pub 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
191pub 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 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
287fn 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
295const EDITION_2023: i32 = 1000;
298
299fn 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
306fn 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
328struct TypeResolver<'a> {
341 ctx: buffa_codegen::context::CodeGenContext<'a>,
342 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 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 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 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
406fn 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
417fn generate_connect_services(
423 file: &FileDescriptorProto,
424 resolver: &TypeResolver<'_>,
425) -> Result<TokenStream> {
426 let mut tokens = TokenStream::new();
427
428 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
454fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub trait #ext_trait_name: #trait_name {
735 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 pub fn new(transport: T, config: ClientConfig) -> Self {
765 Self { transport, config }
766 }
767
768 pub fn config(&self) -> &ClientConfig {
770 &self.config
771 }
772
773 pub fn config_mut(&mut self) -> &mut ClientConfig {
775 &mut self.config
776 }
777
778 #(#client_methods)*
779 }
780 })
781}
782
783fn 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 let path_prefix = format!("{full_service_name}/");
799
800 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 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 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 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 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 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 pub fn new(service: T) -> Self {
920 Self { inner: Arc::new(service) }
921 }
922
923 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 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
1017fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
1019 let comment = if doc.is_empty() { default } else { doc };
1020 doc_attrs(comment)
1021}
1022
1023fn 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 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 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 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 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 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 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
1089fn 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 let ret_ty: TokenStream;
1125 let call_body: TokenStream;
1126 let short_args: TokenStream; let opts_args: TokenStream; let short_delegate_args: TokenStream; if client_streaming && !server_streaming {
1131 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 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 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 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
1219fn get_service_comment(
1221 file: &FileDescriptorProto,
1222 service: &ServiceDescriptorProto,
1223) -> Option<String> {
1224 let source_info: &SourceCodeInfo = &file.source_code_info;
1226
1227 let service_index = file.service.iter().position(|s| s.name == service.name)?;
1229
1230 let target_path = vec![6, service_index as i32];
1233
1234 find_comment(source_info, &target_path)
1235}
1236
1237fn 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 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 let target_path = vec![6, service_index as i32, 2, method_index as i32];
1261
1262 find_comment(source_info, &target_path)
1263}
1264
1265fn 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 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 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 assert!(out.contains("/// Hello."), "got: {out}");
1311 assert!(out.contains("/// Second paragraph."), "got: {out}");
1312 assert!(out.contains("///\n"), "got: {out}");
1314 assert!(!out.contains("///Hello"), "got: {out}");
1316 assert!(!out.contains("/// Hello"), "got: {out}");
1317 }
1318
1319 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 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 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 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 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 assert!(code.contains("PingReq"), "input type missing: {code}");
1459 assert!(code.contains("PingResp"), "output type missing: {code}");
1460 assert!(
1462 !code.contains("super :: PingReq"),
1463 "unexpected super: {code}"
1464 );
1465 }
1466
1467 #[test]
1468 fn cross_package_types_use_relative_paths() {
1469 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 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 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 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 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 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 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 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 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 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 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 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 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 generate(&request).expect("no_register_fn should be a recognized plugin option");
1792 }
1793}