1use std::collections::HashMap;
12
13use anyhow::Result;
14use heck::ToSnakeCase;
15use heck::ToUpperCamelCase;
16use proc_macro2::{Ident, TokenStream};
17use quote::format_ident;
18use quote::quote;
19
20use buffa_codegen::generated::descriptor::FileDescriptorProto;
21use buffa_codegen::generated::descriptor::MethodDescriptorProto;
22use buffa_codegen::generated::descriptor::ServiceDescriptorProto;
23use buffa_codegen::generated::descriptor::SourceCodeInfo;
24use buffa_codegen::generated::descriptor::method_options::IdempotencyLevel;
25use buffa_codegen::idents::make_field_ident;
26use buffa_codegen::idents::rust_path_to_tokens;
27
28pub use buffa_codegen::generated::descriptor;
29pub use buffa_codegen::{CodeGenConfig, GeneratedFile, GeneratedFileKind};
30
31use crate::plugin::CodeGeneratorRequest;
32use crate::plugin::CodeGeneratorResponse;
33use crate::plugin::CodeGeneratorResponseFile;
34
35#[derive(Debug, Clone)]
44#[non_exhaustive]
45pub struct Options {
46 pub buffa: CodeGenConfig,
60}
61
62impl Default for Options {
63 fn default() -> Self {
64 let mut buffa = CodeGenConfig::default();
65 buffa.generate_json = true;
66 Self { buffa }
67 }
68}
69
70impl Options {
71 fn to_buffa_config(&self) -> CodeGenConfig {
74 let mut config = self.buffa.clone();
75 config.generate_views = true;
76 config
77 }
78}
79
80fn emit_service_files(
83 proto_file: &[FileDescriptorProto],
84 file_to_generate: &[String],
85 resolver: &TypeResolver<'_>,
86) -> Result<Vec<GeneratedFile>> {
87 let mut out = Vec::new();
88 let mut batch = BatchState {
96 colliding_aliases: collect_alias_collisions(proto_file, file_to_generate),
97 ..BatchState::default()
98 };
99 for file_name in file_to_generate {
100 let file_desc = proto_file
101 .iter()
102 .find(|f| f.name.as_deref() == Some(file_name.as_str()));
103
104 if let Some(file) = file_desc
105 && !file.service.is_empty()
106 {
107 let service_tokens = generate_connect_services(file, resolver, &mut batch)?;
108 let service_code = format_token_stream(&service_tokens)?;
109 out.push(GeneratedFile {
117 name: format!(
118 "{}.__connect.rs",
119 buffa_codegen::proto_path_to_stem(file_name)
120 ),
121 package: file.package.clone().unwrap_or_default(),
122 kind: GeneratedFileKind::Companion,
123 content: service_code,
124 });
125 }
126 }
127 Ok(out)
128}
129
130pub fn generate_files(
156 proto_file: &[FileDescriptorProto],
157 file_to_generate: &[String],
158 options: &Options,
159) -> Result<Vec<GeneratedFile>> {
160 let config = options.to_buffa_config();
161
162 let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
163 .map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
164
165 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
166 let service_files = emit_service_files(proto_file, file_to_generate, &resolver)?;
167
168 if config.file_per_package {
169 inline_companions_into_package_mods(&mut files, service_files);
177 } else {
178 buffa_codegen::apply_companions(&mut files, service_files);
185
186 debug_assert!(
193 files.iter().all(|f| {
194 f.kind != GeneratedFileKind::Companion
195 || files.iter().any(|g| {
196 g.kind == GeneratedFileKind::PackageMod
197 && g.content.contains(&format!("include!(\"{}\")", f.name))
198 })
199 }),
200 "a companion service file was not wired into any package stitcher"
201 );
202 }
203
204 Ok(files)
205}
206
207fn inline_companions_into_package_mods(
226 files: &mut [GeneratedFile],
229 companions: Vec<GeneratedFile>,
230) {
231 debug_assert!(
235 companions.iter().all(|c| files
236 .iter()
237 .any(|f| f.kind == GeneratedFileKind::PackageMod && f.package == c.package)),
238 "a companion service file's package has no PackageMod to inline into"
239 );
240 for comp in companions {
241 if let Some(pkg_mod) = files
242 .iter_mut()
243 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == comp.package)
244 {
245 pkg_mod.content.push('\n');
246 pkg_mod.content.push_str(&comp.content);
247 }
248 }
249}
250
251pub fn generate_services(
286 proto_file: &[FileDescriptorProto],
287 file_to_generate: &[String],
288 options: &Options,
289) -> Result<Vec<GeneratedFile>> {
290 use std::collections::BTreeMap;
291
292 let config = options.to_buffa_config();
293 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
294 let mut files = emit_service_files(proto_file, file_to_generate, &resolver)?;
295
296 if config.file_per_package {
297 let mut by_package: BTreeMap<String, String> = BTreeMap::new();
302 for f in files {
303 let entry = by_package.entry(f.package).or_insert_with(|| {
304 String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n")
305 });
306 entry.push('\n');
307 entry.push_str(&f.content);
308 }
309 return Ok(by_package
310 .into_iter()
311 .map(|(package, content)| GeneratedFile {
312 name: buffa_codegen::package_to_filename(&package),
313 package,
314 kind: GeneratedFileKind::PackageMod,
315 content,
316 })
317 .collect());
318 }
319
320 let mut by_package: BTreeMap<String, Vec<String>> = BTreeMap::new();
326 for f in &files {
327 by_package
328 .entry(f.package.clone())
329 .or_default()
330 .push(f.name.clone());
331 }
332 for (package, names) in by_package {
333 let mut content = String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n");
334 for n in &names {
335 content.push_str(&format!("include!({n:?});\n"));
337 }
338 files.push(GeneratedFile {
339 name: buffa_codegen::package_to_mod_filename(&package),
340 package,
341 kind: GeneratedFileKind::PackageMod,
342 content,
343 });
344 }
345
346 Ok(files)
347}
348
349pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
427 let mut options = Options::default();
428
429 if let Some(ref param) = request.parameter {
430 for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
431 if let Some(value) = opt.strip_prefix("buffa_module=") {
432 let rust = value.trim();
433 if rust.is_empty() {
434 anyhow::bail!(
435 "buffa_module requires a non-empty path, \
436 e.g. buffa_module=crate::proto"
437 );
438 }
439 options
440 .buffa
441 .extern_paths
442 .push((".".into(), rust.to_string()));
443 } else if let Some(value) = opt.strip_prefix("extern_path=") {
444 let (proto, rust) = value.split_once('=').ok_or_else(|| {
446 anyhow::anyhow!(
447 "invalid extern_path format {value:?}, expected \
448 extern_path=.proto.pkg=::rust::path"
449 )
450 })?;
451 let proto = proto.trim();
452 let rust = rust.trim();
453 if proto.is_empty() || rust.is_empty() {
454 anyhow::bail!(
455 "invalid extern_path format {value:?}, expected \
456 extern_path=.proto.pkg=::rust::path (both sides non-empty)"
457 );
458 }
459 let mut proto = proto.to_string();
460 if !proto.starts_with('.') {
461 proto.insert(0, '.');
462 }
463 options.buffa.extern_paths.push((proto, rust.to_string()));
464 } else {
465 match opt {
466 "file_per_package" => options.buffa.file_per_package = true,
467 "strict_utf8_mapping" => options.buffa.strict_utf8_mapping = true,
468 "no_json" => options.buffa.generate_json = false,
469 "no_register_fn" => options.buffa.emit_register_fn = false,
470 _ => {
471 return Err(anyhow::anyhow!(
472 "unknown plugin option: {opt:?}. Supported: \
473 buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
474 file_per_package, strict_utf8_mapping, no_json, \
475 no_register_fn"
476 ));
477 }
478 }
479 }
480 }
481 }
482
483 let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
484
485 let files: Vec<CodeGeneratorResponseFile> = generated
486 .into_iter()
487 .map(|g| CodeGeneratorResponseFile {
488 name: Some(g.name),
489 content: Some(g.content),
490 ..Default::default()
491 })
492 .collect();
493
494 Ok(CodeGeneratorResponse {
495 supported_features: Some(feature_flags()),
496 minimum_edition: Some(EDITION_2023),
497 maximum_edition: Some(EDITION_2023),
498 file: files,
499 ..Default::default()
500 })
501}
502
503fn feature_flags() -> u64 {
506 const FEATURE_PROTO3_OPTIONAL: u64 = 1;
507 const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
508 FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
509}
510
511const EDITION_2023: i32 = 1000;
514
515fn format_token_stream(tokens: &TokenStream) -> Result<String> {
517 let file = syn::parse2::<syn::File>(tokens.clone())
518 .map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
519 Ok(prettyplease::unparse(&file))
520}
521
522fn doc_attrs(text: &str) -> TokenStream {
531 let lines: Vec<String> = text
532 .lines()
533 .map(|l| {
534 if l.is_empty() {
535 String::new()
536 } else {
537 format!(" {l}")
538 }
539 })
540 .collect();
541 quote! { #(#[doc = #lines])* }
542}
543
544struct TypeResolver<'a> {
557 ctx: buffa_codegen::context::CodeGenContext<'a>,
558 require_extern: bool,
564}
565
566impl<'a> TypeResolver<'a> {
567 fn new(
568 proto_file: &'a [FileDescriptorProto],
569 file_to_generate: &[String],
570 config: &'a buffa_codegen::CodeGenConfig,
571 require_extern: bool,
572 ) -> Self {
573 Self {
574 ctx: buffa_codegen::context::CodeGenContext::for_generate(
575 proto_file,
576 file_to_generate,
577 config,
578 ),
579 require_extern,
580 }
581 }
582
583 fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
590 match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
591 Some(path) => {
592 self.check_extern_coverage(proto_fqn, &path)?;
593 Ok(path)
594 }
595 None => self.fallback_unresolved(proto_fqn).map(str::to_string),
596 }
597 }
598
599 fn check_extern_coverage(&self, proto_fqn: &str, path_prefix: &str) -> Result<()> {
603 if self.require_extern
604 && !path_prefix.starts_with("::")
605 && !path_prefix.starts_with("crate::")
606 {
607 anyhow::bail!(
608 "type {proto_fqn} is not covered by any extern_path mapping. \
609 Add extern_path=.=<your_buffa_module> (e.g. \
610 extern_path=.=crate::proto) to the plugin opts."
611 );
612 }
613 Ok(())
614 }
615
616 fn fallback_unresolved<'f>(&self, proto_fqn: &'f str) -> Result<&'f str> {
620 if self.require_extern {
621 anyhow::bail!("type {proto_fqn} not found in descriptor set (missing proto import?)");
622 }
623 Ok(bare_type_name(proto_fqn))
624 }
625
626 fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
628 let path = self.resolve_path(proto_fqn, current_package)?;
629 Ok(rust_path_to_tokens(&path))
630 }
631
632 fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
639 use buffa_codegen::context::SENTINEL_MOD;
640 let (to_package, within) =
641 match self
642 .ctx
643 .rust_type_relative_split(proto_fqn, current_package, 0)
644 {
645 Some(s) => {
646 self.check_extern_coverage(proto_fqn, &s.to_package)?;
647 (s.to_package, s.within_package)
648 }
649 None => (
650 String::new(),
651 self.fallback_unresolved(proto_fqn)?.to_string(),
652 ),
653 };
654 let prefix = if to_package.is_empty() {
655 format!("{SENTINEL_MOD}::view")
656 } else {
657 format!("{to_package}::{SENTINEL_MOD}::view")
658 };
659 Ok(rust_path_to_tokens(&format!("{prefix}::{within}View")))
660 }
661}
662
663fn bare_type_name(proto_fqn: &str) -> &str {
666 proto_fqn
667 .strip_prefix('.')
668 .unwrap_or(proto_fqn)
669 .rsplit('.')
670 .next()
671 .unwrap_or(proto_fqn)
672}
673
674#[derive(Default)]
681struct BatchState {
682 encodable_seen: std::collections::BTreeSet<String>,
685 alias_seen: std::collections::BTreeSet<(String, String)>,
689 colliding_aliases: std::collections::BTreeSet<(String, String)>,
702}
703
704fn generate_connect_services(
705 file: &FileDescriptorProto,
706 resolver: &TypeResolver<'_>,
707 batch: &mut BatchState,
708) -> Result<TokenStream> {
709 let mut tokens = TokenStream::new();
710
711 tokens.extend(generate_owned_view_aliases(file, resolver, batch)?);
717 tokens.extend(generate_encodable_view_impls(file, resolver, batch)?);
718
719 for service in &file.service {
720 tokens.extend(generate_service(file, service, resolver, batch)?);
721 }
722
723 Ok(tokens)
724}
725
726fn owned_view_alias_ident(fqn: &str) -> Ident {
729 format_ident!("Owned{}View", bare_type_name(fqn).to_upper_camel_case())
730}
731
732fn alias_collides(batch: &BatchState, current_package: &str, proto_fqn: &str) -> bool {
740 let alias = owned_view_alias_ident(proto_fqn).to_string();
741 batch
742 .colliding_aliases
743 .contains(&(current_package.to_string(), alias))
744}
745
746fn owned_view_input_arg_type(
753 resolver: &TypeResolver<'_>,
754 batch: &BatchState,
755 proto_fqn: &str,
756 current_package: &str,
757) -> Result<TokenStream> {
758 if alias_collides(batch, current_package, proto_fqn) {
759 let view = resolver.rust_view_type(proto_fqn, current_package)?;
760 Ok(quote!(::buffa::view::OwnedView<#view<'static>>))
761 } else {
762 let alias = owned_view_alias_ident(proto_fqn);
763 Ok(quote!(#alias))
764 }
765}
766
767fn collect_alias_collisions(
779 proto_file: &[FileDescriptorProto],
780 file_to_generate: &[String],
781) -> std::collections::BTreeSet<(String, String)> {
782 use std::collections::BTreeMap;
783 let mut first_seen: BTreeMap<(String, String), String> = BTreeMap::new();
786 let mut colliding: std::collections::BTreeSet<(String, String)> =
787 std::collections::BTreeSet::new();
788
789 for file_name in file_to_generate {
790 let Some(file) = proto_file
791 .iter()
792 .find(|f| f.name.as_deref() == Some(file_name.as_str()))
793 else {
794 continue;
795 };
796 let package = file.package.clone().unwrap_or_default();
797 for service in &file.service {
798 for m in &service.method {
799 for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
800 .into_iter()
801 .flatten()
802 {
803 let alias = owned_view_alias_ident(fqn).to_string();
804 let key = (package.clone(), alias);
805 match first_seen.get(&key) {
806 Some(prev) if prev != fqn => {
807 colliding.insert(key);
808 }
809 Some(_) => {} None => {
811 first_seen.insert(key, fqn.to_string());
812 }
813 }
814 }
815 }
816 }
817 }
818 colliding
819}
820
821fn generate_owned_view_aliases(
839 file: &FileDescriptorProto,
840 resolver: &TypeResolver<'_>,
841 batch: &mut BatchState,
842) -> Result<TokenStream> {
843 let package = file.package.as_deref().unwrap_or("");
844 let mut out = TokenStream::new();
845 for service in &file.service {
846 for m in &service.method {
847 for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
848 .into_iter()
849 .flatten()
850 {
851 if alias_collides(batch, package, fqn) {
852 continue;
853 }
854 if !batch
855 .alias_seen
856 .insert((package.to_string(), fqn.to_string()))
857 {
858 continue;
859 }
860 let alias = owned_view_alias_ident(fqn);
861 let view = resolver.rust_view_type(fqn, package)?;
862 let doc = format!(
863 "Shorthand for `OwnedView<{}View<'static>>`.",
864 bare_type_name(fqn).to_upper_camel_case()
865 );
866 out.extend(quote! {
867 #[doc = #doc]
868 pub type #alias = ::buffa::view::OwnedView<#view<'static>>;
869 });
870 }
871 }
872 }
873 Ok(out)
874}
875
876fn generate_encodable_view_impls(
892 file: &FileDescriptorProto,
893 resolver: &TypeResolver<'_>,
894 batch: &mut BatchState,
895) -> Result<TokenStream> {
896 let package = file.package.as_deref().unwrap_or("");
897 let mut out = TokenStream::new();
898 for service in &file.service {
899 for m in &service.method {
900 let fqn = m.output_type.as_deref().unwrap_or("");
901 if !batch.encodable_seen.insert(fqn.to_string()) {
902 continue;
903 }
904 let path = resolver.resolve_path(fqn, package)?;
905 if path.starts_with("::") {
908 continue;
909 }
910 let owned = resolver.rust_type(fqn, package)?;
911 let view = resolver.rust_view_type(fqn, package)?;
912 out.extend(quote! {
913 impl ::connectrpc::Encodable<#owned> for #view<'_> {
914 fn encode(&self, codec: ::connectrpc::CodecFormat)
915 -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
916 {
917 ::connectrpc::__codegen::encode_view_body(self, codec)
918 }
919 }
920 impl ::connectrpc::Encodable<#owned> for ::buffa::view::OwnedView<#view<'static>> {
921 fn encode(&self, codec: ::connectrpc::CodecFormat)
922 -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
923 {
924 ::connectrpc::__codegen::encode_view_body(&**self, codec)
925 }
926 }
927 });
928 }
929 }
930 Ok(out)
931}
932
933fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
942 let mut seen: HashMap<String, String> = HashMap::new();
943 for m in &service.method {
944 let proto_name = m.name.as_deref().unwrap_or("");
945 let snake = proto_name.to_snake_case();
946 let with_opts = format!("{snake}_with_options");
947 for ident in [snake.as_str(), with_opts.as_str()] {
948 if let Some(prev) = seen.get(ident) {
949 anyhow::bail!(
950 "service {service_name}: RPC methods {prev:?} and {proto_name:?} \
951 both generate Rust identifier `{ident}`; rename one in the proto"
952 );
953 }
954 }
955 seen.insert(snake, proto_name.to_string());
956 seen.insert(with_opts, proto_name.to_string());
957 }
958 Ok(())
959}
960
961fn generate_service(
962 file: &FileDescriptorProto,
963 service: &ServiceDescriptorProto,
964 resolver: &TypeResolver<'_>,
965 batch: &BatchState,
966) -> Result<TokenStream> {
967 let package = file.package.as_deref().unwrap_or("");
968 let service_name = service.name.as_deref().unwrap_or("");
969 check_method_collisions(service_name, service)?;
970 let full_service_name = if package.is_empty() {
973 service_name.to_string()
974 } else {
975 format!("{package}.{service_name}")
976 };
977 let service_upper = service_name.to_upper_camel_case();
978 let trait_name = if service_upper == "Self" {
982 format_ident!("Self_")
983 } else {
984 format_ident!("{}", service_upper)
985 };
986 let ext_trait_name = format_ident!("{}Ext", service_upper);
987 let client_name = format_ident!("{}Client", service_upper);
988 let server_name = format_ident!("{}Server", service_upper);
989 let service_name_const = format_ident!(
990 "{}_SERVICE_NAME",
991 service_name.to_snake_case().to_uppercase()
992 );
993
994 let service_doc = get_service_comment(file, service).unwrap_or_default();
996 let base_doc = if service_doc.is_empty() {
997 format!("Server trait for {service_name}.")
998 } else {
999 service_doc
1000 };
1001 let full_doc = format!(
1002 "{base_doc}\n\n\
1003 # Implementing handlers\n\n\
1004 Handlers receive requests as `OwnedFooView` (an alias for\n\
1005 `OwnedView<FooView<'static>>`), which gives zero-copy borrowed access\n\
1006 to fields (e.g. `request.name` is a `&str` into the decoded buffer).\n\
1007 The view can be held across `.await` points. When two RPC types in\n\
1008 the same package would alias to the same `Owned<…>View` name (e.g.\n\
1009 a local message plus an imported one with the same short name), the\n\
1010 alias is suppressed for both and the request type is spelled as\n\
1011 `OwnedView<…View<'static>>` directly in the trait signature.\n\n\
1012 Implement methods with plain `async fn`; the returned future satisfies\n\
1013 the `Send` bound automatically. See the\n\
1014 [buffa user guide](https://github.com/anthropics/buffa/blob/main/docs/guide.md#ownedview-in-async-trait-implementations)\n\
1015 for zero-copy access patterns and when `to_owned_message()` is needed.\n\n\
1016 The `impl Encodable<Out>` return bound accepts the owned `Out`, the\n\
1017 generated `OutView<'_>` / `OwnedOutView`, or\n\
1018 [`MaybeBorrowed`](::connectrpc::MaybeBorrowed). View bodies are not\n\
1019 emitted for output types mapped via `extern_path` (the impl would be\n\
1020 an orphan); return owned for WKT/extern outputs."
1021 );
1022 let service_doc_tokens = doc_attrs(&full_doc);
1023
1024 let trait_methods: Vec<TokenStream> = service
1026 .method
1027 .iter()
1028 .map(|m| generate_trait_method(file, service, m, resolver, batch, package))
1029 .collect::<Result<Vec<_>>>()?;
1030
1031 let route_registrations: Vec<TokenStream> = service
1033 .method
1034 .iter()
1035 .map(|m| {
1036 let method_name = m.name.as_deref().unwrap_or("");
1037 let method_snake = make_field_ident(&method_name.to_snake_case());
1038
1039 let client_streaming = m.client_streaming.unwrap_or(false);
1040 let server_streaming = m.server_streaming.unwrap_or(false);
1041
1042 if server_streaming && !client_streaming {
1043 quote! {
1045 .route_view_server_stream(
1046 #service_name_const,
1047 #method_name,
1048 ::connectrpc::view_streaming_handler_fn({
1049 let svc = ::std::sync::Arc::clone(&self);
1050 move |ctx, req| {
1051 let svc = ::std::sync::Arc::clone(&svc);
1052 async move { svc.#method_snake(ctx, req).await }
1053 }
1054 }),
1055 )
1056 }
1057 } else if client_streaming && !server_streaming {
1058 let output_type = resolver
1060 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1061 .unwrap();
1062 quote! {
1063 .route_view_client_stream(
1064 #service_name_const,
1065 #method_name,
1066 ::connectrpc::view_client_streaming_handler_fn({
1067 let svc = ::std::sync::Arc::clone(&self);
1068 move |ctx, req, format| {
1069 let svc = ::std::sync::Arc::clone(&svc);
1070 async move {
1071 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1072 }
1073 }
1074 }),
1075 )
1076 }
1077 } else if client_streaming && server_streaming {
1078 quote! {
1080 .route_view_bidi_stream(
1081 #service_name_const,
1082 #method_name,
1083 ::connectrpc::view_bidi_streaming_handler_fn({
1084 let svc = ::std::sync::Arc::clone(&self);
1085 move |ctx, req| {
1086 let svc = ::std::sync::Arc::clone(&svc);
1087 async move { svc.#method_snake(ctx, req).await }
1088 }
1089 }),
1090 )
1091 }
1092 } else {
1093 let is_idempotent = m
1095 .options
1096 .idempotency_level
1097 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1098 .unwrap_or(false);
1099
1100 let route_method = if is_idempotent {
1101 quote! { route_view_idempotent }
1102 } else {
1103 quote! { route_view }
1104 };
1105 let output_type = resolver
1106 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1107 .unwrap();
1108
1109 quote! {
1110 .#route_method(
1111 #service_name_const,
1112 #method_name,
1113 {
1114 let svc = ::std::sync::Arc::clone(&self);
1115 ::connectrpc::view_handler_fn(move |ctx, req, format| {
1116 let svc = ::std::sync::Arc::clone(&svc);
1117 async move {
1118 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1119 }
1120 })
1121 },
1122 )
1123 }
1124 }
1125 })
1126 .collect();
1127
1128 let client_methods: Vec<TokenStream> = service
1130 .method
1131 .iter()
1132 .map(|m| {
1133 generate_client_method(
1134 &service_name_const,
1135 &full_service_name,
1136 m,
1137 resolver,
1138 package,
1139 )
1140 })
1141 .collect::<Result<Vec<_>>>()?;
1142
1143 let service_server = generate_service_server(
1145 &full_service_name,
1146 &trait_name,
1147 &server_name,
1148 service,
1149 resolver,
1150 package,
1151 )?;
1152
1153 let example_method = service
1155 .method
1156 .first()
1157 .and_then(|m| m.name.as_deref())
1158 .map(|n| make_field_ident(&n.to_snake_case()).to_string())
1159 .unwrap_or_else(|| "method".to_string());
1160
1161 let client_name_str = client_name.to_string();
1163 let client_doc = format!(
1164 r#"Client for this service.
1165
1166Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
1167`Http2Connection` — it has honest `poll_ready` and composes with
1168`tower::balance` for multi-connection load balancing. For **Connect
1169over HTTP/1.1** (or unknown protocol), use `HttpClient`.
1170
1171# Example (gRPC / HTTP/2)
1172
1173```rust,ignore
1174use connectrpc::client::{{Http2Connection, ClientConfig}};
1175use connectrpc::Protocol;
1176
1177let uri: http::Uri = "http://localhost:8080".parse()?;
1178let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
1179let config = ClientConfig::new(uri).protocol(Protocol::Grpc);
1180
1181let client = {client_name_str}::new(conn, config);
1182let response = client.{example_method}(request).await?;
1183```
1184
1185# Example (Connect / HTTP/1.1 or ALPN)
1186
1187```rust,ignore
1188use connectrpc::client::{{HttpClient, ClientConfig}};
1189
1190let http = HttpClient::plaintext(); // cleartext http:// only
1191let config = ClientConfig::new("http://localhost:8080".parse()?);
1192
1193let client = {client_name_str}::new(http, config);
1194let response = client.{example_method}(request).await?;
1195```
1196
1197# Working with the response
1198
1199Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
1200The `OwnedView` derefs to the view, so field access is zero-copy:
1201
1202```rust,ignore
1203let resp = client.{example_method}(request).await?.into_view();
1204let name: &str = resp.name; // borrow into the response buffer
1205```
1206
1207If you need the owned struct (e.g. to store or pass by value), use
1208[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
1209
1210```rust,ignore
1211let owned = client.{example_method}(request).await?.into_owned();
1212```"#
1213 );
1214 let client_doc_tokens = doc_attrs(&client_doc);
1215
1216 Ok(quote! {
1217 pub const #service_name_const: &str = #full_service_name;
1223
1224 #service_doc_tokens
1225 #[allow(clippy::type_complexity)]
1226 pub trait #trait_name: Send + Sync + 'static {
1227 #(#trait_methods)*
1228 }
1229
1230 pub trait #ext_trait_name: #trait_name {
1243 fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router;
1248 }
1249
1250 impl<S: #trait_name> #ext_trait_name for S {
1251 fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router {
1252 router
1253 #(#route_registrations)*
1254 }
1255 }
1256
1257 #service_server
1258
1259 #client_doc_tokens
1260 #[derive(Clone)]
1261 pub struct #client_name<T> {
1262 transport: T,
1263 config: ::connectrpc::client::ClientConfig,
1264 }
1265
1266 impl<T> #client_name<T>
1267 where
1268 T: ::connectrpc::client::ClientTransport,
1269 <T::ResponseBody as ::http_body::Body>::Error: ::std::fmt::Display,
1270 {
1271 pub fn new(transport: T, config: ::connectrpc::client::ClientConfig) -> Self {
1273 Self { transport, config }
1274 }
1275
1276 pub fn config(&self) -> &::connectrpc::client::ClientConfig {
1278 &self.config
1279 }
1280
1281 pub fn config_mut(&mut self) -> &mut ::connectrpc::client::ClientConfig {
1283 &mut self.config
1284 }
1285
1286 #(#client_methods)*
1287 }
1288 })
1289}
1290
1291fn generate_service_server(
1298 full_service_name: &str,
1299 trait_name: &proc_macro2::Ident,
1300 server_name: &proc_macro2::Ident,
1301 service: &ServiceDescriptorProto,
1302 resolver: &TypeResolver<'_>,
1303 package: &str,
1304) -> Result<TokenStream> {
1305 let path_prefix = format!("{full_service_name}/");
1307
1308 let lookup_arms: Vec<TokenStream> = service
1310 .method
1311 .iter()
1312 .map(|m| {
1313 let method_name = m.name.as_deref().unwrap_or("");
1314 let client_streaming = m.client_streaming.unwrap_or(false);
1315 let server_streaming = m.server_streaming.unwrap_or(false);
1316 let is_idempotent = m
1317 .options
1318 .idempotency_level
1319 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1320 .unwrap_or(false);
1321
1322 let desc = if client_streaming && server_streaming {
1323 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::bidi_streaming() }
1324 } else if client_streaming {
1325 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::client_streaming() }
1326 } else if server_streaming {
1327 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::server_streaming() }
1328 } else {
1329 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::unary(#is_idempotent) }
1330 };
1331 quote! { #method_name => Some(#desc), }
1332 })
1333 .collect();
1334
1335 let mut call_unary_arms: Vec<TokenStream> = Vec::new();
1340 let mut call_ss_arms: Vec<TokenStream> = Vec::new();
1341 let mut call_cs_arms: Vec<TokenStream> = Vec::new();
1342 let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
1343
1344 for m in &service.method {
1345 let method_name = m.name.as_deref().unwrap_or("");
1346 let method_snake = make_field_ident(&method_name.to_snake_case());
1347 let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
1348 let output_type = resolver.rust_type(m.output_type.as_deref().unwrap_or(""), package)?;
1349 let cs = m.client_streaming.unwrap_or(false);
1350 let ss = m.server_streaming.unwrap_or(false);
1351
1352 if cs && ss {
1353 call_bidi_arms.push(quote! {
1355 #method_name => {
1356 let svc = ::std::sync::Arc::clone(&self.inner);
1357 Box::pin(async move {
1358 let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
1359 let resp = svc.#method_snake(ctx, req_stream).await?;
1360 Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream(s, format)))
1361 })
1362 }
1363 });
1364 } else if cs {
1365 call_cs_arms.push(quote! {
1367 #method_name => {
1368 let svc = ::std::sync::Arc::clone(&self.inner);
1369 Box::pin(async move {
1370 let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
1371 svc.#method_snake(ctx, req_stream).await?.encode::<#output_type>(format)
1372 })
1373 }
1374 });
1375 } else if ss {
1376 call_ss_arms.push(quote! {
1378 #method_name => {
1379 let svc = ::std::sync::Arc::clone(&self.inner);
1380 Box::pin(async move {
1381 let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request, format)?;
1382 let resp = svc.#method_snake(ctx, req).await?;
1383 Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream(s, format)))
1384 })
1385 }
1386 });
1387 } else {
1388 call_unary_arms.push(quote! {
1390 #method_name => {
1391 let svc = ::std::sync::Arc::clone(&self.inner);
1392 Box::pin(async move {
1393 let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request, format)?;
1394 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1395 })
1396 }
1397 });
1398 }
1399 }
1400
1401 let server_doc = format!(
1402 "Monomorphic dispatcher for `{trait_name}`.\n\n\
1403 Unlike `.register(Router)` which type-erases each method into an \
1404 `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
1405 via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
1406 # Example\n\n\
1407 ```rust,ignore\n\
1408 use connectrpc::ConnectRpcService;\n\n\
1409 let server = {server_name}::new(MyImpl);\n\
1410 let service = ConnectRpcService::new(server);\n\
1411 // hand `service` to axum/hyper as a fallback_service\n\
1412 ```"
1413 );
1414 let server_doc_tokens = doc_attrs(&server_doc);
1415
1416 Ok(quote! {
1417 #server_doc_tokens
1418 pub struct #server_name<T> {
1419 inner: ::std::sync::Arc<T>,
1420 }
1421
1422 impl<T: #trait_name> #server_name<T> {
1423 pub fn new(service: T) -> Self {
1425 Self { inner: ::std::sync::Arc::new(service) }
1426 }
1427
1428 pub fn from_arc(inner: ::std::sync::Arc<T>) -> Self {
1430 Self { inner }
1431 }
1432 }
1433
1434 impl<T> Clone for #server_name<T> {
1435 fn clone(&self) -> Self {
1436 Self { inner: ::std::sync::Arc::clone(&self.inner) }
1437 }
1438 }
1439
1440 impl<T: #trait_name> ::connectrpc::Dispatcher for #server_name<T> {
1441 #[inline]
1442 fn lookup(&self, path: &str) -> Option<::connectrpc::dispatcher::codegen::MethodDescriptor> {
1443 let method = path.strip_prefix(#path_prefix)?;
1444 match method {
1445 #(#lookup_arms)*
1446 _ => None,
1447 }
1448 }
1449
1450 fn call_unary(
1451 &self,
1452 path: &str,
1453 ctx: ::connectrpc::RequestContext,
1454 request: ::buffa::bytes::Bytes,
1455 format: ::connectrpc::CodecFormat,
1456 ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
1457 let Some(method) = path.strip_prefix(#path_prefix) else {
1458 return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
1459 };
1460 let _ = (&ctx, &request, &format);
1462 match method {
1463 #(#call_unary_arms)*
1464 _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
1465 }
1466 }
1467
1468 fn call_server_streaming(
1469 &self,
1470 path: &str,
1471 ctx: ::connectrpc::RequestContext,
1472 request: ::buffa::bytes::Bytes,
1473 format: ::connectrpc::CodecFormat,
1474 ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
1475 let Some(method) = path.strip_prefix(#path_prefix) else {
1476 return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
1477 };
1478 let _ = (&ctx, &request, &format);
1479 match method {
1480 #(#call_ss_arms)*
1481 _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
1482 }
1483 }
1484
1485 fn call_client_streaming(
1486 &self,
1487 path: &str,
1488 ctx: ::connectrpc::RequestContext,
1489 requests: ::connectrpc::dispatcher::codegen::RequestStream,
1490 format: ::connectrpc::CodecFormat,
1491 ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
1492 let Some(method) = path.strip_prefix(#path_prefix) else {
1493 return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
1494 };
1495 let _ = (&ctx, &requests, &format);
1496 match method {
1497 #(#call_cs_arms)*
1498 _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
1499 }
1500 }
1501
1502 fn call_bidi_streaming(
1503 &self,
1504 path: &str,
1505 ctx: ::connectrpc::RequestContext,
1506 requests: ::connectrpc::dispatcher::codegen::RequestStream,
1507 format: ::connectrpc::CodecFormat,
1508 ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
1509 let Some(method) = path.strip_prefix(#path_prefix) else {
1510 return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
1511 };
1512 let _ = (&ctx, &requests, &format);
1513 match method {
1514 #(#call_bidi_arms)*
1515 _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
1516 }
1517 }
1518 }
1519 })
1520}
1521
1522fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
1524 let comment = if doc.is_empty() { default } else { doc };
1525 doc_attrs(comment)
1526}
1527
1528fn generate_trait_method(
1530 file: &FileDescriptorProto,
1531 service: &ServiceDescriptorProto,
1532 method: &MethodDescriptorProto,
1533 resolver: &TypeResolver<'_>,
1534 batch: &BatchState,
1535 package: &str,
1536) -> Result<TokenStream> {
1537 let method_name = method.name.as_deref().unwrap_or("");
1538 let method_snake = make_field_ident(&method_name.to_snake_case());
1539 let input_arg = owned_view_input_arg_type(
1540 resolver,
1541 batch,
1542 method.input_type.as_deref().unwrap_or(""),
1543 package,
1544 )?;
1545 let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
1546
1547 let method_doc = get_method_comment(file, service, method).unwrap_or_default();
1549 let method_doc_tokens =
1550 generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
1551
1552 let client_streaming = method.client_streaming.unwrap_or(false);
1554 let server_streaming = method.server_streaming.unwrap_or(false);
1555
1556 let borrow_doc = quote! {
1557 #[doc = ""]
1558 #[doc = " `'a` lets the response body borrow from `&self` (e.g. server-resident state)."]
1559 };
1560
1561 if server_streaming && !client_streaming {
1562 Ok(quote! {
1564 #method_doc_tokens
1565 fn #method_snake(
1566 &self,
1567 ctx: ::connectrpc::RequestContext,
1568 request: #input_arg,
1569 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<#output_type>>> + Send;
1570 })
1571 } else if client_streaming && !server_streaming {
1572 Ok(quote! {
1574 #method_doc_tokens
1575 #borrow_doc
1576 fn #method_snake<'a>(
1577 &'a self,
1578 ctx: ::connectrpc::RequestContext,
1579 requests: ::connectrpc::ServiceStream<#input_arg>,
1580 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
1581 })
1582 } else if client_streaming && server_streaming {
1583 Ok(quote! {
1585 #method_doc_tokens
1586 fn #method_snake(
1587 &self,
1588 ctx: ::connectrpc::RequestContext,
1589 requests: ::connectrpc::ServiceStream<#input_arg>,
1590 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<#output_type>>> + Send;
1591 })
1592 } else {
1593 Ok(quote! {
1595 #method_doc_tokens
1596 #borrow_doc
1597 fn #method_snake<'a>(
1598 &'a self,
1599 ctx: ::connectrpc::RequestContext,
1600 request: #input_arg,
1601 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
1602 })
1603 }
1604}
1605
1606fn generate_client_method(
1617 service_name_const: &Ident,
1618 full_service_name: &str,
1619 method: &MethodDescriptorProto,
1620 resolver: &TypeResolver<'_>,
1621 package: &str,
1622) -> Result<TokenStream> {
1623 let method_name = method.name.as_deref().unwrap_or("");
1624 let method_snake = make_field_ident(&method_name.to_snake_case());
1625 let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
1626 let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
1627 let output_view_type =
1628 resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
1629
1630 let client_streaming = method.client_streaming.unwrap_or(false);
1631 let server_streaming = method.server_streaming.unwrap_or(false);
1632
1633 let doc = format!(
1634 " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
1635 );
1636 let doc_opts = format!(
1637 " Call the {method_name} RPC with explicit per-call options. \
1638 Options override [`ClientConfig`](::connectrpc::client::ClientConfig) defaults."
1639 );
1640
1641 let ret_ty: TokenStream;
1643 let call_body: TokenStream;
1644 let short_args: TokenStream; let opts_args: TokenStream; let short_delegate_args: TokenStream; if client_streaming && !server_streaming {
1649 ret_ty = quote! {
1651 Result<
1652 ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
1653 ::connectrpc::ConnectError,
1654 >
1655 };
1656 call_body = quote! {
1657 ::connectrpc::client::call_client_stream(
1658 &self.transport, &self.config,
1659 #service_name_const, #method_name,
1660 requests, options,
1661 ).await
1662 };
1663 short_args = quote! { requests: impl IntoIterator<Item = #input_type> };
1664 opts_args = quote! { requests: impl IntoIterator<Item = #input_type>, options: ::connectrpc::client::CallOptions };
1665 short_delegate_args = quote! { requests, ::connectrpc::client::CallOptions::default() };
1666 } else if client_streaming && server_streaming {
1667 ret_ty = quote! {
1669 Result<
1670 ::connectrpc::client::BidiStream<
1671 T::ResponseBody, #input_type, #output_view_type<'static>
1672 >,
1673 ::connectrpc::ConnectError,
1674 >
1675 };
1676 call_body = quote! {
1677 ::connectrpc::client::call_bidi_stream(
1678 &self.transport, &self.config,
1679 #service_name_const, #method_name, options,
1680 ).await
1681 };
1682 short_args = quote! {};
1683 opts_args = quote! { options: ::connectrpc::client::CallOptions };
1684 short_delegate_args = quote! { ::connectrpc::client::CallOptions::default() };
1685 } else if server_streaming {
1686 ret_ty = quote! {
1688 Result<
1689 ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
1690 ::connectrpc::ConnectError,
1691 >
1692 };
1693 call_body = quote! {
1694 ::connectrpc::client::call_server_stream(
1695 &self.transport, &self.config,
1696 #service_name_const, #method_name,
1697 request, options,
1698 ).await
1699 };
1700 short_args = quote! { request: #input_type };
1701 opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
1702 short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
1703 } else {
1704 ret_ty = quote! {
1706 Result<
1707 ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
1708 ::connectrpc::ConnectError,
1709 >
1710 };
1711 call_body = quote! {
1712 ::connectrpc::client::call_unary(
1713 &self.transport, &self.config,
1714 #service_name_const, #method_name,
1715 request, options,
1716 ).await
1717 };
1718 short_args = quote! { request: #input_type };
1719 opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
1720 short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
1721 }
1722
1723 Ok(quote! {
1724 #[doc = #doc]
1725 pub async fn #method_snake(&self, #short_args) -> #ret_ty {
1726 self.#method_with_opts(#short_delegate_args).await
1727 }
1728
1729 #[doc = #doc_opts]
1730 pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
1731 #call_body
1732 }
1733 })
1734}
1735
1736fn get_service_comment(
1738 file: &FileDescriptorProto,
1739 service: &ServiceDescriptorProto,
1740) -> Option<String> {
1741 let source_info: &SourceCodeInfo = &file.source_code_info;
1743
1744 let service_index = file.service.iter().position(|s| s.name == service.name)?;
1746
1747 let target_path = vec![6, service_index as i32];
1750
1751 find_comment(source_info, &target_path)
1752}
1753
1754fn get_method_comment(
1756 file: &FileDescriptorProto,
1757 service: &ServiceDescriptorProto,
1758 method: &MethodDescriptorProto,
1759) -> Option<String> {
1760 let source_info: &SourceCodeInfo = &file.source_code_info;
1761
1762 let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
1765 if s.name != service.name {
1766 return None;
1767 }
1768 s.method
1769 .iter()
1770 .position(|m| m.name == method.name)
1771 .map(|mi| (si, mi))
1772 })?;
1773
1774 let target_path = vec![6, service_index as i32, 2, method_index as i32];
1778
1779 find_comment(source_info, &target_path)
1780}
1781
1782fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
1784 for location in &source_info.location {
1785 if location.path == target_path {
1786 let comment = location
1787 .leading_comments
1788 .as_ref()
1789 .or(location.trailing_comments.as_ref())?;
1790
1791 let cleaned: String = comment
1795 .lines()
1796 .map(|line| line.trim())
1797 .filter(|line| !line.is_empty())
1798 .collect::<Vec<_>>()
1799 .join("\n");
1800
1801 if !cleaned.is_empty() {
1802 return Some(cleaned);
1803 }
1804 }
1805 }
1806 None
1807}
1808
1809#[cfg(test)]
1810mod tests {
1811 use super::*;
1812 use buffa_codegen::generated::descriptor::DescriptorProto;
1813
1814 #[test]
1815 fn doc_attrs_prefixes_space_for_prettyplease() {
1816 let ts = quote! {
1819 #[allow(dead_code)]
1820 mod m {}
1821 };
1822 let doc = doc_attrs("Hello.\n\nSecond paragraph.");
1823 let combined = quote! { #doc #ts };
1824 let file = syn::parse2::<syn::File>(combined).unwrap();
1825 let out = prettyplease::unparse(&file);
1826 assert!(out.contains("/// Hello."), "got: {out}");
1828 assert!(out.contains("/// Second paragraph."), "got: {out}");
1829 assert!(out.contains("///\n"), "got: {out}");
1831 assert!(!out.contains("///Hello"), "got: {out}");
1833 assert!(!out.contains("/// Hello"), "got: {out}");
1834 }
1835
1836 fn minimal_file(
1841 package: Option<&str>,
1842 input_type: &str,
1843 output_type: &str,
1844 local_messages: &[&str],
1845 ) -> FileDescriptorProto {
1846 minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
1847 }
1848
1849 fn minimal_file_with_method(
1852 package: Option<&str>,
1853 method_name: &str,
1854 input_type: &str,
1855 output_type: &str,
1856 local_messages: &[&str],
1857 ) -> FileDescriptorProto {
1858 let method = MethodDescriptorProto {
1859 name: Some(method_name.into()),
1860 input_type: Some(input_type.into()),
1861 output_type: Some(output_type.into()),
1862 ..Default::default()
1863 };
1864 let service = ServiceDescriptorProto {
1865 name: Some("PingService".into()),
1866 method: vec![method],
1867 ..Default::default()
1868 };
1869 FileDescriptorProto {
1870 name: Some("ping.proto".into()),
1871 package: package.map(|p| p.into()),
1872 service: vec![service],
1873 message_type: local_messages
1874 .iter()
1875 .map(|name| DescriptorProto {
1876 name: Some((*name).into()),
1877 ..Default::default()
1878 })
1879 .collect(),
1880 ..Default::default()
1881 }
1882 }
1883
1884 fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
1888 let methods = method_names
1889 .iter()
1890 .map(|n| MethodDescriptorProto {
1891 name: Some((*n).into()),
1892 input_type: Some(format!(".{package}.Empty")),
1893 output_type: Some(format!(".{package}.Empty")),
1894 ..Default::default()
1895 })
1896 .collect();
1897 let service = ServiceDescriptorProto {
1898 name: Some("PingService".into()),
1899 method: methods,
1900 ..Default::default()
1901 };
1902 FileDescriptorProto {
1903 name: Some("ping.proto".into()),
1904 package: Some(package.into()),
1905 service: vec![service],
1906 message_type: vec![DescriptorProto {
1907 name: Some("Empty".into()),
1908 ..Default::default()
1909 }],
1910 ..Default::default()
1911 }
1912 }
1913
1914 fn gen_service(
1923 files: &[FileDescriptorProto],
1924 target_idx: usize,
1925 extern_paths: &[(String, String)],
1926 require_extern: bool,
1927 ) -> Result<String> {
1928 let mut config = buffa_codegen::CodeGenConfig::default();
1929 config.extern_paths = extern_paths.to_vec();
1930 let target_name = files[target_idx]
1931 .name
1932 .clone()
1933 .into_iter()
1934 .collect::<Vec<_>>();
1935 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
1936 let file = &files[target_idx];
1937 let service = &file.service[0];
1938 let batch = BatchState {
1939 colliding_aliases: collect_alias_collisions(files, &target_name),
1940 ..BatchState::default()
1941 };
1942 Ok(generate_service(file, service, &resolver, &batch)?.to_string())
1943 }
1944
1945 fn assert_no_top_level_use(formatted: &str, label: &str) {
1950 let parsed: syn::File = syn::parse_str(formatted).expect("formatted code parses");
1951 let offenders: Vec<String> = parsed
1952 .items
1953 .iter()
1954 .filter_map(|item| match item {
1955 syn::Item::Use(u) => Some(quote!(#u).to_string()),
1956 _ => None,
1957 })
1958 .collect();
1959 assert!(
1960 offenders.is_empty(),
1961 "{label} contains top-level use statement(s): {offenders:?}\nFull source:\n{formatted}"
1962 );
1963 }
1964
1965 fn gen_file(
1966 files: &[FileDescriptorProto],
1967 target_idx: usize,
1968 extern_paths: &[(String, String)],
1969 require_extern: bool,
1970 ) -> Result<String> {
1971 let mut config = buffa_codegen::CodeGenConfig::default();
1972 config.extern_paths = extern_paths.to_vec();
1973 let target_name = files[target_idx]
1974 .name
1975 .clone()
1976 .into_iter()
1977 .collect::<Vec<_>>();
1978 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
1979 let mut batch = BatchState {
1980 colliding_aliases: collect_alias_collisions(files, &target_name),
1981 ..BatchState::default()
1982 };
1983 Ok(generate_connect_services(&files[target_idx], &resolver, &mut batch)?.to_string())
1984 }
1985
1986 #[test]
1987 fn unary_response_body_captures_self_lifetime() {
1988 let file = minimal_file(
1989 Some("example.v1"),
1990 ".example.v1.PingReq",
1991 ".example.v1.PingResp",
1992 &["PingReq", "PingResp"],
1993 );
1994 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1995 assert!(code.contains("< 'a >"), "trait method missing 'a: {code}");
1996 assert!(code.contains("& 'a self"), "missing &'a self: {code}");
1997 assert!(
1998 code.contains("use < 'a , Self >"),
1999 "missing use<'a, Self> capture: {code}"
2000 );
2001 assert!(
2002 !code.contains("'static + use"),
2003 "'static bound on body should be dropped: {code}"
2004 );
2005 }
2006
2007 #[test]
2008 fn owned_view_aliases_emitted_for_input_and_output() {
2009 let file = minimal_file(
2010 Some("example.v1"),
2011 ".example.v1.PingReq",
2012 ".example.v1.PingResp",
2013 &["PingReq", "PingResp"],
2014 );
2015 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2016 assert!(
2017 code.contains("pub type OwnedPingReqView = :: buffa :: view :: OwnedView"),
2018 "missing OwnedPingReqView alias: {code}"
2019 );
2020 assert!(
2021 code.contains("pub type OwnedPingRespView = :: buffa :: view :: OwnedView"),
2022 "missing OwnedPingRespView alias: {code}"
2023 );
2024 assert!(
2026 code.contains("request : OwnedPingReqView ,"),
2027 "trait method should take request: OwnedPingReqView: {code}"
2028 );
2029 }
2030
2031 #[test]
2032 fn cross_package_input_collision_suppresses_alias_for_both_sides() {
2033 let v1 = FileDescriptorProto {
2041 name: Some("api/v1/foo/bar/foobar.proto".into()),
2042 package: Some("api.v1.foo.bar".into()),
2043 message_type: vec![DescriptorProto {
2044 name: Some("MyMessage".into()),
2045 ..Default::default()
2046 }],
2047 ..Default::default()
2048 };
2049 let v2 = minimal_file(
2050 Some("api.v2.foo.bar"),
2051 ".api.v1.foo.bar.MyMessage",
2052 ".api.v2.foo.bar.MyMessage",
2053 &["MyMessage"],
2054 );
2055 let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2056
2057 let alias_count = code.matches("pub type OwnedMyMessageView").count();
2060 assert_eq!(
2061 alias_count, 0,
2062 "expected zero OwnedMyMessageView aliases when both sides collide; got {alias_count}: {code}"
2063 );
2064
2065 assert!(
2068 !code.contains("request : OwnedMyMessageView"),
2069 "colliding input must not reference the suppressed alias: {code}"
2070 );
2071 assert!(
2072 code.contains("request : :: buffa :: view :: OwnedView <"),
2073 "colliding input should be inlined as OwnedView<…<'static>>: {code}"
2074 );
2075 }
2076
2077 #[test]
2078 fn cross_package_input_without_collision_keeps_alias() {
2079 let wkt = FileDescriptorProto {
2086 name: Some("google/protobuf/empty.proto".into()),
2087 package: Some("google.protobuf".into()),
2088 message_type: vec![DescriptorProto {
2089 name: Some("Empty".into()),
2090 ..Default::default()
2091 }],
2092 ..Default::default()
2093 };
2094 let svc = minimal_file(
2095 Some("example.v1"),
2096 ".google.protobuf.Empty",
2097 ".example.v1.PingResp",
2098 &["PingResp"],
2099 );
2100 let code = gen_file(&[wkt, svc], 1, &[], false).unwrap();
2101 assert!(
2102 code.contains("pub type OwnedEmptyView = :: buffa :: view :: OwnedView"),
2103 "WKT cross-package input should keep its alias: {code}"
2104 );
2105 assert!(
2106 code.contains("request : OwnedEmptyView ,"),
2107 "trait method should still use OwnedEmptyView for non-colliding cross-package input: {code}"
2108 );
2109 }
2110
2111 #[test]
2112 fn collision_inlines_in_all_streaming_method_shapes() {
2113 let v1 = FileDescriptorProto {
2119 name: Some("api/v1/foo/bar/foobar.proto".into()),
2120 package: Some("api.v1.foo.bar".into()),
2121 message_type: vec![DescriptorProto {
2122 name: Some("MyMessage".into()),
2123 ..Default::default()
2124 }],
2125 ..Default::default()
2126 };
2127 let v2 = FileDescriptorProto {
2128 name: Some("api/v2/foo/bar/foobar.proto".into()),
2129 package: Some("api.v2.foo.bar".into()),
2130 message_type: vec![DescriptorProto {
2131 name: Some("MyMessage".into()),
2132 ..Default::default()
2133 }],
2134 service: vec![ServiceDescriptorProto {
2135 name: Some("FooBar".into()),
2136 method: vec![
2137 MethodDescriptorProto {
2138 name: Some("Unary".into()),
2139 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2140 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2141 ..Default::default()
2142 },
2143 MethodDescriptorProto {
2144 name: Some("ServerStream".into()),
2145 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2146 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2147 server_streaming: Some(true),
2148 ..Default::default()
2149 },
2150 MethodDescriptorProto {
2151 name: Some("ClientStream".into()),
2152 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2153 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2154 client_streaming: Some(true),
2155 ..Default::default()
2156 },
2157 MethodDescriptorProto {
2158 name: Some("Bidi".into()),
2159 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2160 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2161 client_streaming: Some(true),
2162 server_streaming: Some(true),
2163 ..Default::default()
2164 },
2165 ],
2166 ..Default::default()
2167 }],
2168 ..Default::default()
2169 };
2170 let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2171
2172 assert!(
2174 !code.contains("OwnedMyMessageView"),
2175 "no method shape should reference the suppressed alias: {code}"
2176 );
2177
2178 assert!(
2182 code.matches("request : :: buffa :: view :: OwnedView <")
2183 .count()
2184 >= 2,
2185 "unary and server-streaming should both inline the request type: {code}"
2186 );
2187 assert!(
2188 code.matches(
2189 "requests : :: connectrpc :: ServiceStream < :: buffa :: view :: OwnedView <"
2190 )
2191 .count()
2192 >= 2,
2193 "client-streaming and bidi should both inline the streamed request type: {code}"
2194 );
2195 }
2196
2197 #[test]
2198 fn encodable_view_impls_emitted_per_output_type() {
2199 let file = minimal_file(
2200 Some("example.v1"),
2201 ".example.v1.PingReq",
2202 ".example.v1.PingResp",
2203 &["PingReq", "PingResp"],
2204 );
2205 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2206 assert!(
2207 code.contains(
2208 ":: connectrpc :: Encodable < PingResp > for __buffa :: view :: PingRespView"
2209 ),
2210 "missing Encodable<PingResp> for PingRespView: {code}"
2211 );
2212 assert!(
2213 code.contains(
2214 ":: connectrpc :: Encodable < PingResp > for :: buffa :: view :: OwnedView"
2215 ),
2216 "missing Encodable<PingResp> for OwnedView<PingRespView>: {code}"
2217 );
2218 assert!(!code.contains("Encodable < PingReq >"), "got: {code}");
2220 }
2221
2222 #[test]
2223 fn encodable_view_impls_skipped_for_extern_output() {
2224 let wkt = FileDescriptorProto {
2227 name: Some("google/protobuf/empty.proto".into()),
2228 package: Some("google.protobuf".into()),
2229 message_type: vec![DescriptorProto {
2230 name: Some("Empty".into()),
2231 ..Default::default()
2232 }],
2233 ..Default::default()
2234 };
2235 let file = minimal_file(
2236 Some("example.v1"),
2237 ".example.v1.PingReq",
2238 ".google.protobuf.Empty",
2239 &["PingReq"],
2240 );
2241 let code = gen_file(&[wkt, file], 1, &[], false).unwrap();
2242 assert!(
2245 !code.contains("encode_view_body"),
2246 "extern output type must not get Encodable impl: {code}"
2247 );
2248 }
2249
2250 #[test]
2251 fn encodable_view_impls_deduped_across_files() {
2252 let common = FileDescriptorProto {
2257 name: Some("common.proto".into()),
2258 package: Some("common.v1".into()),
2259 message_type: vec![DescriptorProto {
2260 name: Some("Reply".into()),
2261 ..Default::default()
2262 }],
2263 ..Default::default()
2264 };
2265 let svc = |name: &str, pkg: &str| FileDescriptorProto {
2266 name: Some(name.into()),
2267 package: Some(pkg.into()),
2268 message_type: vec![DescriptorProto {
2269 name: Some("Req".into()),
2270 ..Default::default()
2271 }],
2272 service: vec![ServiceDescriptorProto {
2273 name: Some("S".into()),
2274 method: vec![MethodDescriptorProto {
2275 name: Some("Call".into()),
2276 input_type: Some(format!(".{pkg}.Req")),
2277 output_type: Some(".common.v1.Reply".into()),
2278 ..Default::default()
2279 }],
2280 ..Default::default()
2281 }],
2282 ..Default::default()
2283 };
2284 let files = vec![common, svc("a.proto", "a.v1"), svc("b.proto", "b.v1")];
2285
2286 let generated = generate_files(
2287 &files,
2288 &["a.proto".into(), "b.proto".into()],
2289 &Options::default(),
2290 )
2291 .unwrap();
2292
2293 let companions: Vec<_> = generated
2296 .iter()
2297 .filter(|f| f.kind == GeneratedFileKind::Companion)
2298 .collect();
2299 let mut companion_names: Vec<&str> = companions.iter().map(|f| f.name.as_str()).collect();
2300 companion_names.sort_unstable();
2301 assert_eq!(companion_names, ["a.__connect.rs", "b.__connect.rs"]);
2302 for c in &companions {
2303 let stitcher = generated
2304 .iter()
2305 .find(|g| g.kind == GeneratedFileKind::PackageMod && g.package == c.package)
2306 .expect("each companion's package must have a stitcher");
2307 assert!(
2308 stitcher
2309 .content
2310 .contains(&format!("include!(\"{}\")", c.name)),
2311 "stitcher for {} must include companion {}",
2312 c.package,
2313 c.name
2314 );
2315 }
2316
2317 let combined: String = companions.iter().map(|f| f.content.as_str()).collect();
2318
2319 let view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor super::super::common::v1::__buffa::view::ReplyView<'_>";
2320 let owned_view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor ::buffa::view::OwnedView<";
2321 assert_eq!(
2322 combined.matches(view_impl).count(),
2323 1,
2324 "Encodable<Reply> for ReplyView<'_> must appear once: {combined}"
2325 );
2326 assert_eq!(
2327 combined.matches(owned_view_impl).count(),
2328 1,
2329 "Encodable<Reply> for OwnedView<ReplyView> must appear once: {combined}"
2330 );
2331 }
2332
2333 fn file_per_package_fixture() -> Vec<FileDescriptorProto> {
2338 let common = FileDescriptorProto {
2339 name: Some("common.proto".into()),
2340 package: Some("common.v1".into()),
2341 message_type: vec![DescriptorProto {
2342 name: Some("Reply".into()),
2343 ..Default::default()
2344 }],
2345 ..Default::default()
2346 };
2347 let svc = |proto_name: &str, pkg: &str, svc_name: &str, req: &str| FileDescriptorProto {
2352 name: Some(proto_name.into()),
2353 package: Some(pkg.into()),
2354 message_type: vec![DescriptorProto {
2355 name: Some(req.into()),
2356 ..Default::default()
2357 }],
2358 service: vec![ServiceDescriptorProto {
2359 name: Some(svc_name.into()),
2360 method: vec![MethodDescriptorProto {
2361 name: Some("Call".into()),
2362 input_type: Some(format!(".{pkg}.{req}")),
2363 output_type: Some(".common.v1.Reply".into()),
2364 ..Default::default()
2365 }],
2366 ..Default::default()
2367 }],
2368 ..Default::default()
2369 };
2370 vec![
2371 common,
2372 svc("a/x.proto", "a.v1", "XService", "XReq"),
2373 svc("a/y.proto", "a.v1", "YService", "YReq"),
2374 svc("b/z.proto", "b.v1", "ZService", "ZReq"),
2375 ]
2376 }
2377
2378 #[test]
2379 fn generate_files_file_per_package_inlines_companions() {
2380 let files = file_per_package_fixture();
2381 let mut options = Options::default();
2382 options.buffa.file_per_package = true;
2383
2384 let generated = generate_files(
2385 &files,
2386 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2387 &options,
2388 )
2389 .unwrap();
2390
2391 assert!(
2393 !generated
2394 .iter()
2395 .any(|f| f.kind == GeneratedFileKind::Companion),
2396 "file_per_package must not emit sibling Companion files"
2397 );
2398 assert!(
2399 !generated.iter().any(|f| f.name.ends_with(".__connect.rs")),
2400 "file_per_package must not emit `<stem>.__connect.rs` files"
2401 );
2402
2403 let a = generated
2405 .iter()
2406 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "a.v1")
2407 .expect("a.v1 PackageMod must exist");
2408 assert!(
2409 a.content.contains("pub trait XService"),
2410 "a.v1 missing XService"
2411 );
2412 assert!(
2413 a.content.contains("pub trait YService"),
2414 "a.v1 missing YService"
2415 );
2416 assert!(
2417 !a.content.contains("pub trait ZService"),
2418 "a.v1 must not inline ZService"
2419 );
2420 assert!(
2421 !a.content.contains("__connect.rs"),
2422 "a.v1 PackageMod must not include! a connect file: {}",
2423 a.content
2424 );
2425
2426 let b = generated
2427 .iter()
2428 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "b.v1")
2429 .expect("b.v1 PackageMod must exist");
2430 assert!(
2431 b.content.contains("pub trait ZService"),
2432 "b.v1 missing ZService"
2433 );
2434 assert!(
2435 !b.content.contains("pub trait XService"),
2436 "b.v1 must not inline XService"
2437 );
2438
2439 let pkg_mods = generated
2442 .iter()
2443 .filter(|f| f.kind == GeneratedFileKind::PackageMod)
2444 .count();
2445 assert_eq!(
2446 pkg_mods, 2,
2447 "expected exactly two PackageMods: {generated:#?}"
2448 );
2449
2450 let combined: String = generated.iter().map(|f| f.content.as_str()).collect();
2455 assert_eq!(
2456 combined
2457 .matches("impl ::connectrpc::Encodable<super::super::common::v1::Reply>")
2458 .count(),
2459 2,
2460 "Encodable<Reply> impls must be deduplicated across packages \
2461 (1 for ReplyView, 1 for OwnedView<ReplyView>): {combined}"
2462 );
2463 }
2464
2465 #[test]
2466 fn generate_services_file_per_package_emits_one_file_per_package() {
2467 let files = file_per_package_fixture();
2468 let mut options = Options::default();
2469 options.buffa.file_per_package = true;
2470 options
2471 .buffa
2472 .extern_paths
2473 .push((".".into(), "crate::proto".into()));
2474
2475 let generated = generate_services(
2476 &files,
2477 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2478 &options,
2479 )
2480 .unwrap();
2481
2482 assert_eq!(
2485 generated.len(),
2486 2,
2487 "expected exactly two output files: {generated:#?}"
2488 );
2489 assert!(
2490 generated
2491 .iter()
2492 .all(|f| f.kind == GeneratedFileKind::PackageMod),
2493 "all output files must be PackageMod"
2494 );
2495 assert!(
2496 !generated.iter().any(|f| f.name.ends_with(".mod.rs")),
2497 "file_per_package must not emit a separate stitcher"
2498 );
2499 assert!(
2500 !generated.iter().any(|f| f.content.contains("include!")),
2501 "file_per_package output must not include! sibling files"
2502 );
2503
2504 let mut names: Vec<&str> = generated.iter().map(|f| f.name.as_str()).collect();
2505 names.sort_unstable();
2506 assert_eq!(
2507 names,
2508 ["a.v1.rs", "b.v1.rs"],
2509 "filenames must be `<dotted.pkg>.rs` to match buffa's file_per_package convention"
2510 );
2511
2512 let a = generated.iter().find(|f| f.package == "a.v1").unwrap();
2513 assert!(a.content.contains("pub trait XService"));
2514 assert!(a.content.contains("pub trait YService"));
2515 let b = generated.iter().find(|f| f.package == "b.v1").unwrap();
2516 assert!(b.content.contains("pub trait ZService"));
2517 assert!(!b.content.contains("pub trait XService"));
2518 }
2519
2520 #[test]
2521 fn generate_services_file_per_package_default_layout_unchanged() {
2522 let files = file_per_package_fixture();
2525 let mut options = Options::default();
2526 options
2527 .buffa
2528 .extern_paths
2529 .push((".".into(), "crate::proto".into()));
2530
2531 let generated = generate_services(
2532 &files,
2533 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2534 &options,
2535 )
2536 .unwrap();
2537
2538 let mut companions: Vec<&str> = generated
2539 .iter()
2540 .filter(|f| f.kind == GeneratedFileKind::Companion)
2541 .map(|f| f.name.as_str())
2542 .collect();
2543 companions.sort_unstable();
2544 assert_eq!(
2545 companions,
2546 ["a.x.__connect.rs", "a.y.__connect.rs", "b.z.__connect.rs"],
2547 "default layout emits one companion per proto"
2548 );
2549 let mut stitchers: Vec<&str> = generated
2550 .iter()
2551 .filter(|f| f.kind == GeneratedFileKind::PackageMod)
2552 .map(|f| f.name.as_str())
2553 .collect();
2554 stitchers.sort_unstable();
2555 assert_eq!(
2556 stitchers,
2557 ["a.v1.mod.rs", "b.v1.mod.rs"],
2558 "default layout emits one stitcher per package"
2559 );
2560 let a_stitcher = generated.iter().find(|f| f.name == "a.v1.mod.rs").unwrap();
2562 assert!(
2563 a_stitcher
2564 .content
2565 .contains(r#"include!("a.x.__connect.rs");"#)
2566 );
2567 assert!(
2568 a_stitcher
2569 .content
2570 .contains(r#"include!("a.y.__connect.rs");"#)
2571 );
2572 }
2573
2574 #[test]
2575 fn service_name_with_package() {
2576 let file = minimal_file(
2577 Some("example.v1"),
2578 ".example.v1.PingReq",
2579 ".example.v1.PingResp",
2580 &["PingReq", "PingResp"],
2581 );
2582 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2583 assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
2584 }
2585
2586 #[test]
2587 fn service_name_without_package() {
2588 let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
2590 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2591 assert!(code.contains("\"PingService\""), "got: {code}");
2592 assert!(
2593 !code.contains("\".PingService\""),
2594 "must not have leading dot: {code}"
2595 );
2596 }
2597
2598 #[test]
2599 fn same_package_types_use_bare_names() {
2600 let file = minimal_file(
2601 Some("example.v1"),
2602 ".example.v1.PingReq",
2603 ".example.v1.PingResp",
2604 &["PingReq", "PingResp"],
2605 );
2606 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2607 assert!(code.contains("PingReq"), "input type missing: {code}");
2609 assert!(code.contains("PingResp"), "output type missing: {code}");
2610 assert!(
2612 !code.contains("super :: PingReq"),
2613 "unexpected super: {code}"
2614 );
2615 }
2616
2617 #[test]
2618 fn cross_package_types_use_relative_paths() {
2619 let common = FileDescriptorProto {
2623 name: Some("common.proto".into()),
2624 package: Some("common.v1".into()),
2625 message_type: vec![DescriptorProto {
2626 name: Some("Shared".into()),
2627 ..Default::default()
2628 }],
2629 ..Default::default()
2630 };
2631 let svc = minimal_file(
2632 Some("example.v1"),
2633 ".common.v1.Shared",
2634 ".example.v1.Out",
2635 &["Out"],
2636 );
2637 let code = gen_service(&[common, svc], 1, &[], false).unwrap();
2638
2639 assert!(
2642 code.contains("super :: super :: common :: v1 :: Shared"),
2643 "cross-package path not emitted: {code}"
2644 );
2645 assert!(
2646 code.contains("super :: super :: common :: v1 :: __buffa :: view :: SharedView"),
2647 "cross-package view path not emitted: {code}"
2648 );
2649 }
2650
2651 #[test]
2652 fn nested_message_view_type_mirrors_owned_module_nesting() {
2653 let file = FileDescriptorProto {
2658 name: Some("nested.proto".into()),
2659 package: Some("example.v1".into()),
2660 message_type: vec![
2661 DescriptorProto {
2662 name: Some("Outer".into()),
2663 nested_type: vec![DescriptorProto {
2664 name: Some("Inner".into()),
2665 ..Default::default()
2666 }],
2667 ..Default::default()
2668 },
2669 DescriptorProto {
2670 name: Some("Out".into()),
2671 ..Default::default()
2672 },
2673 ],
2674 service: vec![ServiceDescriptorProto {
2675 name: Some("NestedService".into()),
2676 method: vec![MethodDescriptorProto {
2677 name: Some("Ping".into()),
2678 input_type: Some(".example.v1.Outer.Inner".into()),
2679 output_type: Some(".example.v1.Out".into()),
2680 ..Default::default()
2681 }],
2682 ..Default::default()
2683 }],
2684 ..Default::default()
2685 };
2686 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2687
2688 assert!(
2689 code.contains("__buffa :: view :: outer :: InnerView"),
2690 "nested view path not emitted: {code}"
2691 );
2692 assert!(
2693 code.contains("outer :: Inner"),
2694 "nested owned path not emitted: {code}"
2695 );
2696 }
2697
2698 #[test]
2699 fn wkt_types_use_buffa_types_extern_path() {
2700 let wkt = FileDescriptorProto {
2704 name: Some("google/protobuf/empty.proto".into()),
2705 package: Some("google.protobuf".into()),
2706 message_type: vec![DescriptorProto {
2707 name: Some("Empty".into()),
2708 ..Default::default()
2709 }],
2710 ..Default::default()
2711 };
2712 let svc = minimal_file(
2713 Some("example.v1"),
2714 ".google.protobuf.Empty",
2715 ".example.v1.Out",
2716 &["Out"],
2717 );
2718 let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
2719
2720 assert!(
2721 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
2722 "WKT extern path not emitted: {code}"
2723 );
2724 }
2725
2726 #[test]
2727 fn extern_catchall_uses_absolute_paths() {
2728 let file = minimal_file(
2729 Some("example.v1"),
2730 ".example.v1.PingReq",
2731 ".example.v1.PingResp",
2732 &["PingReq", "PingResp"],
2733 );
2734 let extern_paths = [(".".into(), "crate::proto".into())];
2735 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
2736 assert!(
2737 code.contains("crate :: proto :: example :: v1 :: PingReq"),
2738 "owned type path missing: {code}"
2739 );
2740 assert!(
2741 code.contains("crate :: proto :: example :: v1 :: __buffa :: view :: PingReqView"),
2742 "view type path missing: {code}"
2743 );
2744 }
2745
2746 #[test]
2747 fn extern_catchall_with_wkt_longest_wins() {
2748 let wkt = FileDescriptorProto {
2751 name: Some("google/protobuf/empty.proto".into()),
2752 package: Some("google.protobuf".into()),
2753 message_type: vec![DescriptorProto {
2754 name: Some("Empty".into()),
2755 ..Default::default()
2756 }],
2757 ..Default::default()
2758 };
2759 let svc = minimal_file(
2760 Some("example.v1"),
2761 ".google.protobuf.Empty",
2762 ".example.v1.Out",
2763 &["Out"],
2764 );
2765 let extern_paths = [(".".into(), "crate::proto".into())];
2766 let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
2767 assert!(
2768 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
2769 "WKT mapping lost to catch-all: {code}"
2770 );
2771 assert!(
2772 code.contains("crate :: proto :: example :: v1 :: Out"),
2773 "local type not routed through catch-all: {code}"
2774 );
2775 }
2776
2777 #[test]
2778 fn missing_extern_path_errors() {
2779 let file = minimal_file(
2780 Some("example.v1"),
2781 ".example.v1.PingReq",
2782 ".example.v1.PingResp",
2783 &["PingReq", "PingResp"],
2784 );
2785 let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
2786 let msg = err.to_string();
2787 assert!(
2788 msg.contains("extern_path"),
2789 "error message lacks hint: {msg}"
2790 );
2791 }
2792
2793 #[test]
2794 fn keyword_package_escaped() {
2795 let file = minimal_file(
2797 Some("google.type"),
2798 ".google.type.LatLng",
2799 ".google.type.LatLng",
2800 &["LatLng"],
2801 );
2802 let extern_paths = [(".".into(), "crate::proto".into())];
2803 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
2804 assert!(
2805 code.contains("crate :: proto :: google :: r#type :: LatLng"),
2806 "keyword segment not escaped: {code}"
2807 );
2808 }
2809
2810 #[test]
2811 fn keyword_method_escaped() {
2812 let file = minimal_file_with_method(
2815 Some("example.v1"),
2816 "Move",
2817 ".example.v1.Empty",
2818 ".example.v1.Empty",
2819 &["Empty"],
2820 );
2821 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2822 assert!(
2823 code.contains("fn r#move"),
2824 "keyword method not escaped: {code}"
2825 );
2826 assert!(
2827 code.contains("move_with_options"),
2828 "suffixed variant should not need escaping: {code}"
2829 );
2830 assert!(code.contains("client.r#move(request)"));
2832 syn::parse_str::<syn::File>(&code).expect("generated code parses");
2833 }
2834
2835 #[test]
2836 fn path_keyword_method_suffixed() {
2837 let file = minimal_file_with_method(
2840 Some("example.v1"),
2841 "Self",
2842 ".example.v1.Empty",
2843 ".example.v1.Empty",
2844 &["Empty"],
2845 );
2846 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2847 assert!(
2848 code.contains("fn self_"),
2849 "path-keyword method not suffixed: {code}"
2850 );
2851 assert!(code.contains("self_with_options"));
2855 syn::parse_str::<syn::File>(&code).expect("generated code parses");
2856 }
2857
2858 #[test]
2859 fn service_name_keyword_suffixed() {
2860 let mut file = minimal_file(
2864 Some("example.v1"),
2865 ".example.v1.Empty",
2866 ".example.v1.Empty",
2867 &["Empty"],
2868 );
2869 file.service[0].name = Some("Self".into());
2870 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2871 assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
2872 assert!(code.contains("trait SelfExt"));
2873 assert!(code.contains("struct SelfClient"));
2874 assert!(code.contains("struct SelfServer"));
2875 syn::parse_str::<syn::File>(&code).expect("generated code parses");
2876 }
2877
2878 #[test]
2879 fn method_snake_collision_errors() {
2880 let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
2883 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
2884 let msg = err.to_string();
2885 assert!(msg.contains("PingService"), "missing service name: {msg}");
2886 assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
2887 assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
2888 assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
2889 }
2890
2891 #[test]
2892 fn method_with_options_collision_errors() {
2893 let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
2896 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
2897 let msg = err.to_string();
2898 assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
2899 assert!(
2900 msg.contains("\"PingWithOptions\""),
2901 "missing second method: {msg}"
2902 );
2903 assert!(
2904 msg.contains("`ping_with_options`"),
2905 "missing rust ident: {msg}"
2906 );
2907 }
2908
2909 #[test]
2910 fn distinct_methods_do_not_collide() {
2911 let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
2912 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2913 syn::parse_str::<syn::File>(&code).expect("generated code parses");
2914 }
2915
2916 #[test]
2917 fn options_default_buffa_config() {
2918 let cfg = Options::default().to_buffa_config();
2919 assert!(cfg.generate_json, "connectrpc enables JSON by default");
2920 assert!(cfg.generate_views);
2921 assert!(cfg.emit_register_fn);
2922 assert!(!cfg.strict_utf8_mapping);
2923 }
2924
2925 #[test]
2926 fn options_buffa_passthrough_forces_views() {
2927 let mut opts = Options::default();
2928 opts.buffa.emit_register_fn = false;
2929 opts.buffa.generate_views = false;
2930 let cfg = opts.to_buffa_config();
2931 assert!(!cfg.emit_register_fn);
2932 assert!(cfg.generate_views, "generate_views must be forced on");
2933 }
2934
2935 #[test]
2936 fn generate_files_emit_register_fn_false_suppresses_register_types() {
2937 let file = FileDescriptorProto {
2940 name: Some("ping.proto".into()),
2941 package: Some("example.v1".into()),
2942 message_type: vec![DescriptorProto {
2943 name: Some("PingReq".into()),
2944 ..Default::default()
2945 }],
2946 ..Default::default()
2947 };
2948
2949 let stitcher = |files: &[GeneratedFile]| {
2952 files
2953 .iter()
2954 .find(|f| f.kind == GeneratedFileKind::PackageMod)
2955 .expect("PackageMod file emitted")
2956 .content
2957 .clone()
2958 };
2959
2960 let with_fn = generate_files(
2961 std::slice::from_ref(&file),
2962 &["ping.proto".into()],
2963 &Options::default(),
2964 )
2965 .unwrap();
2966 let mod_rs = stitcher(&with_fn);
2967 assert!(
2968 mod_rs.contains("fn register_types"),
2969 "expected register_types in default output: {mod_rs}"
2970 );
2971
2972 let mut opts = Options::default();
2973 opts.buffa.emit_register_fn = false;
2974 let without_fn =
2975 generate_files(std::slice::from_ref(&file), &["ping.proto".into()], &opts).unwrap();
2976 let mod_rs = stitcher(&without_fn);
2977 assert!(
2978 !mod_rs.contains("fn register_types"),
2979 "register_types should be suppressed: {mod_rs}"
2980 );
2981 }
2982
2983 #[test]
2984 fn plugin_no_register_fn_parses() {
2985 let request = CodeGeneratorRequest {
2986 parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
2987 file_to_generate: vec![],
2988 proto_file: vec![],
2989 ..Default::default()
2990 };
2991 generate(&request).expect("no_register_fn should be a recognized plugin option");
2994 }
2995
2996 #[test]
2997 fn plugin_file_per_package_collapses_output() {
2998 let request = CodeGeneratorRequest {
3001 parameter: Some("buffa_module=crate::proto,file_per_package".into()),
3002 file_to_generate: vec!["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3003 proto_file: file_per_package_fixture(),
3004 ..Default::default()
3005 };
3006 let response = generate(&request).expect("file_per_package should parse and generate");
3007 let mut names: Vec<&str> = response
3008 .file
3009 .iter()
3010 .filter_map(|f| f.name.as_deref())
3011 .collect();
3012 names.sort_unstable();
3013 assert_eq!(
3014 names,
3015 ["a.v1.rs", "b.v1.rs"],
3016 "expected one file per package: {names:?}"
3017 );
3018 for f in &response.file {
3019 let content = f.content.as_deref().unwrap_or_default();
3020 assert!(
3021 !content.contains("include!"),
3022 "file_per_package output must be self-contained: {content}"
3023 );
3024 }
3025 }
3026
3027 #[test]
3028 fn no_top_level_use_statements_in_generated_code() {
3029 let file = minimal_file(
3033 Some("example.v1"),
3034 ".example.v1.PingReq",
3035 ".example.v1.PingResp",
3036 &["PingReq", "PingResp"],
3037 );
3038 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3039 let formatted = format_token_stream(&code.parse::<TokenStream>().unwrap()).unwrap();
3040 assert_no_top_level_use(&formatted, "generated code");
3041 }
3042
3043 #[test]
3044 fn multi_service_include_no_e0252() {
3045 let file_a = {
3048 let method = MethodDescriptorProto {
3049 name: Some("Ping".into()),
3050 input_type: Some(".svc.v1.PingReq".into()),
3051 output_type: Some(".svc.v1.PingResp".into()),
3052 ..Default::default()
3053 };
3054 let service = ServiceDescriptorProto {
3055 name: Some("Alpha".into()),
3056 method: vec![method],
3057 ..Default::default()
3058 };
3059 FileDescriptorProto {
3060 name: Some("alpha.proto".into()),
3061 package: Some("svc.v1".into()),
3062 service: vec![service],
3063 message_type: vec![
3064 DescriptorProto {
3065 name: Some("PingReq".into()),
3066 ..Default::default()
3067 },
3068 DescriptorProto {
3069 name: Some("PingResp".into()),
3070 ..Default::default()
3071 },
3072 ],
3073 ..Default::default()
3074 }
3075 };
3076 let file_b = {
3077 let method = MethodDescriptorProto {
3078 name: Some("Pong".into()),
3079 input_type: Some(".svc.v1.PongReq".into()),
3080 output_type: Some(".svc.v1.PongResp".into()),
3081 ..Default::default()
3082 };
3083 let service = ServiceDescriptorProto {
3084 name: Some("Beta".into()),
3085 method: vec![method],
3086 ..Default::default()
3087 };
3088 FileDescriptorProto {
3089 name: Some("beta.proto".into()),
3090 package: Some("svc.v1".into()),
3091 service: vec![service],
3092 message_type: vec![
3093 DescriptorProto {
3094 name: Some("PongReq".into()),
3095 ..Default::default()
3096 },
3097 DescriptorProto {
3098 name: Some("PongResp".into()),
3099 ..Default::default()
3100 },
3101 ],
3102 ..Default::default()
3103 }
3104 };
3105
3106 let files = vec![file_a, file_b];
3107 let config = buffa_codegen::CodeGenConfig::default();
3108 let targets = vec!["alpha.proto".to_string(), "beta.proto".to_string()];
3109 let resolver = TypeResolver::new(&files, &targets, &config, false);
3110
3111 let mut batch = BatchState {
3112 colliding_aliases: collect_alias_collisions(&files, &targets),
3113 ..BatchState::default()
3114 };
3115 let code_a = generate_connect_services(&files[0], &resolver, &mut batch).unwrap();
3116 let code_b = generate_connect_services(&files[1], &resolver, &mut batch).unwrap();
3117
3118 let formatted_a = format_token_stream(&code_a).unwrap();
3119 let formatted_b = format_token_stream(&code_b).unwrap();
3120
3121 syn::parse_str::<syn::File>(&formatted_a).expect("service A should parse independently");
3123 syn::parse_str::<syn::File>(&formatted_b).expect("service B should parse independently");
3124
3125 let combined = format!("{formatted_a}\n{formatted_b}");
3127 syn::parse_str::<syn::File>(&combined)
3128 .expect("combined services should parse without E0252");
3129
3130 assert_no_top_level_use(&formatted_a, "service A");
3132 assert_no_top_level_use(&formatted_b, "service B");
3133 }
3134}