1use std::collections::HashMap;
12
13use anyhow::Result;
14use heck::ToSnakeCase;
15use heck::ToUpperCamelCase;
16use proc_macro2::{Ident, Span, TokenStream};
17use quote::format_ident;
18use quote::quote;
19
20use buffa_codegen::generated::descriptor::DescriptorProto;
21use buffa_codegen::generated::descriptor::FileDescriptorProto;
22use buffa_codegen::generated::descriptor::MethodDescriptorProto;
23use buffa_codegen::generated::descriptor::ServiceDescriptorProto;
24use buffa_codegen::generated::descriptor::SourceCodeInfo;
25use buffa_codegen::generated::descriptor::method_options::IdempotencyLevel;
26use buffa_codegen::idents::make_field_ident;
27use buffa_codegen::idents::rust_path_to_tokens;
28
29pub use buffa_codegen::generated::descriptor;
30pub use buffa_codegen::{CodeGenConfig, GeneratedFile, GeneratedFileKind};
31
32use crate::plugin::CodeGeneratorRequest;
33use crate::plugin::CodeGeneratorResponse;
34use crate::plugin::CodeGeneratorResponseFile;
35
36#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct Options {
47 pub buffa: CodeGenConfig,
70
71 pub gate_client_feature: bool,
76
77 pub client_feature_name: String,
80
81 pub encodable_impls: EncodableImpls,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119#[non_exhaustive]
120pub enum EncodableImpls {
121 #[default]
124 Outputs,
125 AllMessages,
128}
129
130impl Default for Options {
131 fn default() -> Self {
132 let mut buffa = CodeGenConfig::default();
133 buffa.generate_json = true;
134 Self {
135 buffa,
136 gate_client_feature: false,
137 client_feature_name: "client".into(),
138 encodable_impls: EncodableImpls::default(),
139 }
140 }
141}
142
143impl Options {
144 fn to_buffa_config(&self) -> CodeGenConfig {
147 let mut config = self.buffa.clone();
148 config.generate_views = true;
149 config
150 }
151
152 fn client_feature_name(&self) -> Result<&str> {
153 let name = self.client_feature_name.trim();
154 if name.is_empty() {
155 if self.gate_client_feature {
156 anyhow::bail!("client feature name must not be empty");
157 }
158 return Ok("client");
159 }
160 if !buffa_codegen::FeatureGateNames::is_valid_name(name) {
161 anyhow::bail!(
162 "client feature name {name:?} is not a valid Cargo feature name \
163 (must start with an alphanumeric or `_` and contain only \
164 alphanumerics, `_`, `-`, `+`, `.`)"
165 );
166 }
167 Ok(name)
168 }
169}
170
171fn emit_service_files(
177 proto_file: &[FileDescriptorProto],
178 file_to_generate: &[String],
179 resolver: &TypeResolver<'_>,
180 options: &Options,
181 client_feature_name: &str,
182) -> Result<Vec<GeneratedFile>> {
183 let mut out = Vec::new();
184 let mut batch = BatchState {
192 colliding_aliases: collect_alias_collisions(proto_file, file_to_generate),
193 gate_client_feature: options.gate_client_feature,
194 client_feature_name: client_feature_name.to_string(),
195 all_message_encodable_impls: options.encodable_impls == EncodableImpls::AllMessages,
196 ..BatchState::default()
197 };
198 for file_name in file_to_generate {
199 let file_desc = proto_file
200 .iter()
201 .find(|f| f.name.as_deref() == Some(file_name.as_str()));
202
203 if let Some(file) = file_desc
204 && (!file.service.is_empty()
205 || (batch.all_message_encodable_impls && !file.message_type.is_empty()))
206 {
207 let service_tokens = generate_connect_services(file, resolver, &mut batch)?;
208 if service_tokens.is_empty() {
209 debug_assert!(
216 file.service.is_empty(),
217 "service-declaring proto {file_name} produced no service tokens"
218 );
219 continue;
220 }
221 let service_code = format_token_stream(&service_tokens)?;
222 out.push(GeneratedFile {
230 name: format!(
231 "{}.__connect.rs",
232 buffa_codegen::proto_path_to_stem(file_name)
233 ),
234 package: file.package.clone().unwrap_or_default(),
235 kind: GeneratedFileKind::Companion,
236 content: service_code,
237 });
238 }
239 }
240 Ok(out)
241}
242
243pub fn generate_files(
272 proto_file: &[FileDescriptorProto],
273 file_to_generate: &[String],
274 options: &Options,
275) -> Result<Vec<GeneratedFile>> {
276 let config = options.to_buffa_config();
277
278 let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
279 .map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
280
281 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
282 let client_feature_name = options.client_feature_name()?;
283 let service_files = emit_service_files(
284 proto_file,
285 file_to_generate,
286 &resolver,
287 options,
288 client_feature_name,
289 )?;
290
291 if config.file_per_package {
292 inline_companions_into_package_mods(&mut files, service_files);
300 } else {
301 buffa_codegen::apply_companions(&mut files, service_files);
308
309 debug_assert!(
316 files.iter().all(|f| {
317 f.kind != GeneratedFileKind::Companion
318 || files.iter().any(|g| {
319 g.kind == GeneratedFileKind::PackageMod
320 && g.content.contains(&format!("include!(\"{}\")", f.name))
321 })
322 }),
323 "a companion service file was not wired into any package stitcher"
324 );
325 }
326
327 Ok(files)
328}
329
330fn inline_companions_into_package_mods(
349 files: &mut [GeneratedFile],
352 companions: Vec<GeneratedFile>,
353) {
354 debug_assert!(
358 companions.iter().all(|c| files
359 .iter()
360 .any(|f| f.kind == GeneratedFileKind::PackageMod && f.package == c.package)),
361 "a companion service file's package has no PackageMod to inline into"
362 );
363 for comp in companions {
364 if let Some(pkg_mod) = files
365 .iter_mut()
366 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == comp.package)
367 {
368 pkg_mod.content.push('\n');
369 pkg_mod.content.push_str(&comp.content);
370 }
371 }
372}
373
374pub fn generate_services(
411 proto_file: &[FileDescriptorProto],
412 file_to_generate: &[String],
413 options: &Options,
414) -> Result<Vec<GeneratedFile>> {
415 use std::collections::BTreeMap;
416
417 let config = options.to_buffa_config();
418 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
419 let client_feature_name = options.client_feature_name()?;
420 let mut files = emit_service_files(
421 proto_file,
422 file_to_generate,
423 &resolver,
424 options,
425 client_feature_name,
426 )?;
427
428 if config.file_per_package {
429 let mut by_package: BTreeMap<String, String> = BTreeMap::new();
434 for f in files {
435 let entry = by_package.entry(f.package).or_insert_with(|| {
436 String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n")
437 });
438 entry.push('\n');
439 entry.push_str(&f.content);
440 }
441 return Ok(by_package
442 .into_iter()
443 .map(|(package, content)| GeneratedFile {
444 name: buffa_codegen::package_to_filename(&package),
445 package,
446 kind: GeneratedFileKind::PackageMod,
447 content,
448 })
449 .collect());
450 }
451
452 let mut by_package: BTreeMap<String, Vec<String>> = BTreeMap::new();
458 for f in &files {
459 by_package
460 .entry(f.package.clone())
461 .or_default()
462 .push(f.name.clone());
463 }
464 for (package, names) in by_package {
465 let mut content = String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n");
466 for n in &names {
467 content.push_str(&format!("include!({n:?});\n"));
469 }
470 files.push(GeneratedFile {
471 name: buffa_codegen::package_to_mod_filename(&package),
472 package,
473 kind: GeneratedFileKind::PackageMod,
474 content,
475 });
476 }
477
478 Ok(files)
479}
480
481pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
610 let mut options = Options::default();
611
612 if let Some(ref param) = request.parameter {
613 for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
614 if let Some(value) = opt.strip_prefix("buffa_module=") {
615 let rust = value.trim();
616 if rust.is_empty() {
617 anyhow::bail!(
618 "buffa_module requires a non-empty path, \
619 e.g. buffa_module=crate::proto"
620 );
621 }
622 options
623 .buffa
624 .extern_paths
625 .push((".".into(), rust.to_string()));
626 } else if let Some(value) = opt.strip_prefix("extern_path=") {
627 let (proto, rust) = value.split_once('=').ok_or_else(|| {
629 anyhow::anyhow!(
630 "invalid extern_path format {value:?}, expected \
631 extern_path=.proto.pkg=::rust::path"
632 )
633 })?;
634 let proto = proto.trim();
635 let rust = rust.trim();
636 if proto.is_empty() || rust.is_empty() {
637 anyhow::bail!(
638 "invalid extern_path format {value:?}, expected \
639 extern_path=.proto.pkg=::rust::path (both sides non-empty)"
640 );
641 }
642 let mut proto = proto.to_string();
643 if !proto.starts_with('.') {
644 proto.insert(0, '.');
645 }
646 options.buffa.extern_paths.push((proto, rust.to_string()));
647 } else if let Some(value) = opt.strip_prefix("gate_client_feature=") {
648 let feature = value.trim();
649 if feature.is_empty() {
650 anyhow::bail!("gate_client_feature requires a non-empty feature name");
651 }
652 options.gate_client_feature = true;
653 options.client_feature_name = feature.to_string();
654 } else if let Some(value) = opt.strip_prefix("encodable_impls=") {
655 match value.trim() {
656 "all_messages" => options.encodable_impls = EncodableImpls::AllMessages,
657 "outputs" => options.encodable_impls = EncodableImpls::Outputs,
658 other => anyhow::bail!(
659 "invalid encodable_impls value {other:?}, expected \
660 `all_messages` or `outputs`"
661 ),
662 }
663 } else {
664 match opt {
665 "file_per_package" => options.buffa.file_per_package = true,
666 "strict_utf8_mapping" => options.buffa.strict_utf8_mapping = true,
667 "no_json" => options.buffa.generate_json = false,
668 "no_register_fn" => options.buffa.emit_register_fn = false,
669 "gate_client_feature" => options.gate_client_feature = true,
670 _ => {
671 return Err(anyhow::anyhow!(
672 "unknown plugin option: {opt:?}. Supported: \
673 buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
674 encodable_impls=<all_messages|outputs>, \
675 file_per_package, strict_utf8_mapping, no_json, \
676 no_register_fn, gate_client_feature, \
677 gate_client_feature=<name>"
678 ));
679 }
680 }
681 }
682 }
683 }
684
685 let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
686
687 let files: Vec<CodeGeneratorResponseFile> = generated
688 .into_iter()
689 .map(|g| CodeGeneratorResponseFile {
690 name: Some(g.name),
691 content: Some(g.content),
692 ..Default::default()
693 })
694 .collect();
695
696 Ok(CodeGeneratorResponse {
697 supported_features: Some(feature_flags()),
698 minimum_edition: Some(EDITION_2023),
699 maximum_edition: Some(EDITION_2023),
700 file: files,
701 ..Default::default()
702 })
703}
704
705fn feature_flags() -> u64 {
708 const FEATURE_PROTO3_OPTIONAL: u64 = 1;
709 const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
710 FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
711}
712
713const EDITION_2023: i32 = 1000;
716
717fn format_token_stream(tokens: &TokenStream) -> Result<String> {
719 let file = syn::parse2::<syn::File>(tokens.clone())
720 .map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
721 Ok(prettyplease::unparse(&file))
722}
723
724fn doc_attrs(text: &str) -> TokenStream {
733 let lines: Vec<String> = text
734 .lines()
735 .map(|l| {
736 if l.is_empty() {
737 String::new()
738 } else {
739 format!(" {l}")
740 }
741 })
742 .collect();
743 quote! { #(#[doc = #lines])* }
744}
745
746struct TypeResolver<'a> {
759 ctx: buffa_codegen::context::CodeGenContext<'a>,
760 require_extern: bool,
766}
767
768impl<'a> TypeResolver<'a> {
769 fn new(
770 proto_file: &'a [FileDescriptorProto],
771 file_to_generate: &[String],
772 config: &'a buffa_codegen::CodeGenConfig,
773 require_extern: bool,
774 ) -> Self {
775 Self {
776 ctx: buffa_codegen::context::CodeGenContext::for_generate(
777 proto_file,
778 file_to_generate,
779 config,
780 ),
781 require_extern,
782 }
783 }
784
785 fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
792 match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
793 Some(path) => {
794 self.check_extern_coverage(proto_fqn, &path)?;
795 Ok(path)
796 }
797 None => self.fallback_unresolved(proto_fqn).map(str::to_string),
798 }
799 }
800
801 fn check_extern_coverage(&self, proto_fqn: &str, path_prefix: &str) -> Result<()> {
805 if self.require_extern
806 && !path_prefix.starts_with("::")
807 && !path_prefix.starts_with("crate::")
808 {
809 anyhow::bail!(
810 "type {proto_fqn} is not covered by any extern_path mapping. \
811 Add extern_path=.=<your_buffa_module> (e.g. \
812 extern_path=.=crate::proto) to the plugin opts."
813 );
814 }
815 Ok(())
816 }
817
818 fn fallback_unresolved<'f>(&self, proto_fqn: &'f str) -> Result<&'f str> {
822 if self.require_extern {
823 anyhow::bail!("type {proto_fqn} not found in descriptor set (missing proto import?)");
824 }
825 Ok(bare_type_name(proto_fqn))
826 }
827
828 fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
830 let path = self.resolve_path(proto_fqn, current_package)?;
831 Ok(rust_path_to_tokens(&path))
832 }
833
834 fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
841 use buffa_codegen::context::SENTINEL_MOD;
842 let (to_package, within) =
843 match self
844 .ctx
845 .rust_type_relative_split(proto_fqn, current_package, 0)
846 {
847 Some(s) => {
848 self.check_extern_coverage(proto_fqn, &s.to_package)?;
849 (s.to_package, s.within_package)
850 }
851 None => (
852 String::new(),
853 self.fallback_unresolved(proto_fqn)?.to_string(),
854 ),
855 };
856 let prefix = if to_package.is_empty() {
857 format!("{SENTINEL_MOD}::view")
858 } else {
859 format!("{to_package}::{SENTINEL_MOD}::view")
860 };
861 Ok(rust_path_to_tokens(&format!("{prefix}::{within}View")))
862 }
863}
864
865fn bare_type_name(proto_fqn: &str) -> &str {
868 proto_fqn
869 .strip_prefix('.')
870 .unwrap_or(proto_fqn)
871 .rsplit('.')
872 .next()
873 .unwrap_or(proto_fqn)
874}
875
876#[derive(Default)]
883struct BatchState {
884 encodable_seen: std::collections::BTreeSet<String>,
887 alias_seen: std::collections::BTreeSet<(String, String)>,
891 colliding_aliases: std::collections::BTreeSet<(String, String)>,
904 gate_client_feature: bool,
910 client_feature_name: String,
912 all_message_encodable_impls: bool,
916}
917
918impl BatchState {
919 fn client_feature_name(&self) -> &str {
920 if self.client_feature_name.is_empty() {
921 "client"
922 } else {
923 &self.client_feature_name
924 }
925 }
926}
927
928fn generate_connect_services(
929 file: &FileDescriptorProto,
930 resolver: &TypeResolver<'_>,
931 batch: &mut BatchState,
932) -> Result<TokenStream> {
933 let mut tokens = TokenStream::new();
934
935 tokens.extend(generate_owned_view_aliases(file, resolver, batch)?);
945 tokens.extend(generate_encodable_view_impls(file, resolver, batch)?);
946 if batch.all_message_encodable_impls {
947 tokens.extend(generate_all_message_encodable_impls(file, resolver, batch)?);
948 }
949
950 for service in &file.service {
951 tokens.extend(generate_service(file, service, resolver, batch)?);
952 }
953
954 Ok(tokens)
955}
956
957fn owned_view_alias_ident(fqn: &str) -> Ident {
960 format_ident!("Owned{}View", bare_type_name(fqn).to_upper_camel_case())
961}
962
963fn alias_collides(batch: &BatchState, current_package: &str, proto_fqn: &str) -> bool {
971 let alias = owned_view_alias_ident(proto_fqn).to_string();
972 batch
973 .colliding_aliases
974 .contains(&(current_package.to_string(), alias))
975}
976
977fn router_stream_items_tokens(
984 resolver: &TypeResolver<'_>,
985 method: &MethodDescriptorProto,
986 package: &str,
987) -> TokenStream {
988 let input_fqn = method.input_type.as_deref().unwrap_or("");
989 let input_owned = resolver
993 .rust_type(input_fqn, package)
994 .expect("rust_type failed for streaming input type");
995 quote! {
996 let req = ::connectrpc::dispatcher::codegen::into_stream_messages::<#input_owned>(req);
997 }
998}
999
1000fn stream_items_doc(method: &MethodDescriptorProto) -> TokenStream {
1007 let mut doc = quote! {
1008 #[doc = ""]
1009 #[doc = " Each `requests` item is a [`StreamMessage`](::connectrpc::StreamMessage):"]
1010 #[doc = " it owns its buffer, is `Send + 'static`, and exposes zero-copy"]
1011 #[doc = " accessor methods (`item.name()`), `.view()`, and"]
1012 #[doc = " `.to_owned_message()`."]
1013 };
1014 if method.input_type == method.output_type {
1015 doc.extend(quote! {
1016 #[doc = " Items can be yielded back unchanged"]
1017 #[doc = " (`StreamMessage<M>` implements `Encodable<M>`)."]
1018 });
1019 }
1020 doc
1021}
1022
1023fn stream_owned_message_type(
1026 resolver: &TypeResolver<'_>,
1027 method: &MethodDescriptorProto,
1028 package: &str,
1029) -> Result<TokenStream> {
1030 let input_fqn = method.input_type.as_deref().unwrap_or("");
1031 let input_owned = resolver.rust_type(input_fqn, package)?;
1032 Ok(quote! { #input_owned })
1033}
1034
1035fn collect_alias_collisions(
1047 proto_file: &[FileDescriptorProto],
1048 file_to_generate: &[String],
1049) -> std::collections::BTreeSet<(String, String)> {
1050 use std::collections::BTreeMap;
1051 let mut first_seen: BTreeMap<(String, String), String> = BTreeMap::new();
1054 let mut colliding: std::collections::BTreeSet<(String, String)> =
1055 std::collections::BTreeSet::new();
1056
1057 for file_name in file_to_generate {
1058 let Some(file) = proto_file
1059 .iter()
1060 .find(|f| f.name.as_deref() == Some(file_name.as_str()))
1061 else {
1062 continue;
1063 };
1064 let package = file.package.clone().unwrap_or_default();
1065 for service in &file.service {
1066 for m in &service.method {
1067 for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
1068 .into_iter()
1069 .flatten()
1070 {
1071 let alias = owned_view_alias_ident(fqn).to_string();
1072 let key = (package.clone(), alias);
1073 match first_seen.get(&key) {
1074 Some(prev) if prev != fqn => {
1075 colliding.insert(key);
1076 }
1077 Some(_) => {} None => {
1079 first_seen.insert(key, fqn.to_string());
1080 }
1081 }
1082 }
1083 }
1084 }
1085 }
1086 colliding
1087}
1088
1089fn generate_owned_view_aliases(
1106 file: &FileDescriptorProto,
1107 resolver: &TypeResolver<'_>,
1108 batch: &mut BatchState,
1109) -> Result<TokenStream> {
1110 let package = file.package.as_deref().unwrap_or("");
1111 let mut out = TokenStream::new();
1112 for service in &file.service {
1113 for m in &service.method {
1114 for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
1115 .into_iter()
1116 .flatten()
1117 {
1118 if alias_collides(batch, package, fqn) {
1119 continue;
1120 }
1121 if !batch
1122 .alias_seen
1123 .insert((package.to_string(), fqn.to_string()))
1124 {
1125 continue;
1126 }
1127 let alias = owned_view_alias_ident(fqn);
1128 let view = resolver.rust_view_type(fqn, package)?;
1129 let doc = format!(
1130 "Shorthand for `OwnedView<{}View<'static>>`.",
1131 bare_type_name(fqn).to_upper_camel_case()
1132 );
1133 out.extend(quote! {
1134 #[doc = #doc]
1135 pub type #alias = ::buffa::view::OwnedView<#view<'static>>;
1136 });
1137 }
1138 }
1139 }
1140 Ok(out)
1141}
1142
1143fn generate_encodable_view_impls(
1159 file: &FileDescriptorProto,
1160 resolver: &TypeResolver<'_>,
1161 batch: &mut BatchState,
1162) -> Result<TokenStream> {
1163 let package = file.package.as_deref().unwrap_or("");
1164 let mut out = TokenStream::new();
1165 for service in &file.service {
1166 for m in &service.method {
1167 let fqn = m.output_type.as_deref().unwrap_or("");
1168 if let Some(impls) = encodable_impl_pair(fqn, package, resolver, batch)? {
1169 out.extend(impls);
1170 }
1171 }
1172 }
1173 Ok(out)
1174}
1175
1176fn encodable_impl_pair(
1181 fqn: &str,
1182 package: &str,
1183 resolver: &TypeResolver<'_>,
1184 batch: &mut BatchState,
1185) -> Result<Option<TokenStream>> {
1186 if !batch.encodable_seen.insert(fqn.to_string()) {
1187 return Ok(None);
1188 }
1189 let path = resolver.resolve_path(fqn, package)?;
1190 if path.starts_with("::") {
1193 return Ok(None);
1194 }
1195 let owned = resolver.rust_type(fqn, package)?;
1196 let view = resolver.rust_view_type(fqn, package)?;
1197 Ok(Some(quote! {
1198 impl ::connectrpc::Encodable<#owned> for #view<'_> {
1199 fn encode(&self, codec: ::connectrpc::CodecFormat)
1200 -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
1201 {
1202 ::connectrpc::__codegen::encode_view_body(self, codec)
1203 }
1204 }
1205 impl ::connectrpc::Encodable<#owned> for ::buffa::view::OwnedView<#view<'static>> {
1206 fn encode(&self, codec: ::connectrpc::CodecFormat)
1207 -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
1208 {
1209 ::connectrpc::__codegen::encode_view_body(self.reborrow(), codec)
1210 }
1211 }
1212 }))
1213}
1214
1215fn generate_all_message_encodable_impls(
1225 file: &FileDescriptorProto,
1226 resolver: &TypeResolver<'_>,
1227 batch: &mut BatchState,
1228) -> Result<TokenStream> {
1229 fn recurse(
1230 msg: &DescriptorProto,
1231 fqn_prefix: &str,
1232 package: &str,
1233 resolver: &TypeResolver<'_>,
1234 batch: &mut BatchState,
1235 out: &mut TokenStream,
1236 ) -> Result<()> {
1237 if msg
1239 .options
1240 .as_option()
1241 .is_some_and(|o| o.map_entry.unwrap_or(false))
1242 {
1243 return Ok(());
1244 }
1245 let name = msg.name.as_deref().unwrap_or("");
1246 let fqn = format!("{fqn_prefix}.{name}");
1247 if let Some(impls) = encodable_impl_pair(&fqn, package, resolver, batch)? {
1248 out.extend(impls);
1249 }
1250 for nested in &msg.nested_type {
1251 recurse(nested, &fqn, package, resolver, batch, out)?;
1252 }
1253 Ok(())
1254 }
1255
1256 let package = file.package.as_deref().unwrap_or("");
1257 let fqn_prefix = if package.is_empty() {
1258 String::new()
1259 } else {
1260 format!(".{package}")
1261 };
1262 let mut out = TokenStream::new();
1263 for msg in &file.message_type {
1264 recurse(msg, &fqn_prefix, package, resolver, batch, &mut out)?;
1265 }
1266 Ok(out)
1267}
1268
1269fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
1278 let mut seen: HashMap<String, String> = HashMap::new();
1279 for m in &service.method {
1280 let proto_name = m.name.as_deref().unwrap_or("");
1281 let snake = proto_name.to_snake_case();
1282 let with_opts = format!("{snake}_with_options");
1283 for ident in [snake.as_str(), with_opts.as_str()] {
1284 if let Some(prev) = seen.get(ident) {
1285 anyhow::bail!(
1286 "service {service_name}: RPC methods {prev:?} and {proto_name:?} \
1287 both generate Rust identifier `{ident}`; rename one in the proto"
1288 );
1289 }
1290 }
1291 seen.insert(snake, proto_name.to_string());
1292 seen.insert(with_opts, proto_name.to_string());
1293 }
1294 Ok(())
1295}
1296
1297fn generate_service(
1298 file: &FileDescriptorProto,
1299 service: &ServiceDescriptorProto,
1300 resolver: &TypeResolver<'_>,
1301 batch: &BatchState,
1302) -> Result<TokenStream> {
1303 let package = file.package.as_deref().unwrap_or("");
1304 let service_name = service.name.as_deref().unwrap_or("");
1305 check_method_collisions(service_name, service)?;
1306 let full_service_name = if package.is_empty() {
1309 service_name.to_string()
1310 } else {
1311 format!("{package}.{service_name}")
1312 };
1313 let service_upper = service_name.to_upper_camel_case();
1314 let trait_name = if service_upper == "Self" {
1318 format_ident!("Self_")
1319 } else {
1320 format_ident!("{}", service_upper)
1321 };
1322 let ext_trait_name = format_ident!("{}Ext", service_upper);
1323 let register_marker_name = format_ident!("{}RegisterMarker", service_upper);
1324 let client_name = format_ident!("{}Client", service_upper);
1325 let server_name = format_ident!("{}Server", service_upper);
1326 let service_name_const = format_ident!(
1327 "{}_SERVICE_NAME",
1328 service_name.to_snake_case().to_uppercase()
1329 );
1330
1331 let service_doc = get_service_comment(file, service).unwrap_or_default();
1333 let base_doc = if service_doc.is_empty() {
1334 format!("Server trait for {service_name}.")
1335 } else {
1336 service_doc
1337 };
1338 let full_doc = format!(
1339 "{base_doc}\n\n\
1340 # Implementing handlers\n\n\
1341 Implement methods with plain `async fn`; the returned future satisfies\n\
1342 the `Send` bound automatically.\n\n\
1343 **Unary and server-streaming requests** arrive as\n\
1344 [`ServiceRequest<'_, Req>`](::connectrpc::ServiceRequest): a zero-copy\n\
1345 view of the request plus its body, valid for the duration of the call.\n\
1346 Fields are read directly (`request.name` is a `&str` into the decoded\n\
1347 buffer) and the borrow may be held across `.await` points. Anything\n\
1348 that must outlive the call — `tokio::spawn`, channels, server state,\n\
1349 or data captured by a returned response stream — takes owned data:\n\
1350 call `request.to_owned_message()` (or copy the specific fields)\n\
1351 first.\n\n\
1352 **Client-streaming and bidi requests** arrive as\n\
1353 [`InboundStream<Req>`](::connectrpc::InboundStream) — a\n\
1354 `ServiceStream` of [`StreamMessage`](::connectrpc::StreamMessage)s.\n\
1355 Each item owns its decoded buffer and is `Send + 'static`, so items\n\
1356 can be buffered or moved into spawned tasks; read fields zero-copy\n\
1357 through the generated accessor methods (`item.name()`) or `.view()`,\n\
1358 convert with `.to_owned_message()`, or yield an item back unchanged —\n\
1359 `StreamMessage<M>` implements `Encodable<M>`.\n\n\
1360 Request types resolved through `extern_path` (e.g. well-known types\n\
1361 from another crate) use the same wrappers; the crate that owns the\n\
1362 type must be generated with buffa ≥ 0.8.0 and views enabled so the\n\
1363 backing `HasMessageView` impl exists.\n\n\
1364 The `impl Encodable<Out>` return bound accepts the owned `Out`, the\n\
1365 generated `OutView<'_>` / `OwnedOutView`,\n\
1366 [`MaybeBorrowed`](::connectrpc::MaybeBorrowed), or\n\
1367 [`PreEncoded`](::connectrpc::PreEncoded) for handlers that encode a\n\
1368 non-`'static` view internally and pass the bytes across the handler\n\
1369 boundary. View bodies are not emitted for output types mapped via\n\
1370 `extern_path` (the impl would be an orphan); return owned for\n\
1371 WKT/extern outputs.\n\n\
1372 Server-streaming and bidi-streaming methods return\n\
1373 `ServiceStream<impl Encodable<Out> + Send + use<Self>>`. The\n\
1374 `use<Self>` precise-capturing clause excludes `&self`'s lifetime and\n\
1375 the request's lifetime (unary methods use `use<'a, Self>` and may\n\
1376 borrow from `&self`), so stream items must be `'static` and cannot\n\
1377 borrow from the request. To stream view-encoded data, encode each\n\
1378 item inside the stream body and yield\n\
1379 [`PreEncoded`](::connectrpc::PreEncoded) — see its `# Streaming\n\
1380 example` doc."
1381 );
1382 let service_doc_tokens = doc_attrs(&full_doc);
1383
1384 let trait_methods: Vec<TokenStream> = service
1386 .method
1387 .iter()
1388 .map(|m| generate_trait_method(file, service, m, resolver, package))
1389 .collect::<Result<Vec<_>>>()?;
1390
1391 let route_registrations: Vec<TokenStream> = service
1393 .method
1394 .iter()
1395 .map(|m| {
1396 let method_name = m.name.as_deref().unwrap_or("");
1397 let method_snake = make_field_ident(&method_name.to_snake_case());
1398 let spec_const = method_spec_const_ident(service, method_name);
1402
1403 let client_streaming = m.client_streaming.unwrap_or(false);
1404 let server_streaming = m.server_streaming.unwrap_or(false);
1405
1406 let route_call = if server_streaming && !client_streaming {
1407 let output_type = resolver
1412 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1413 .unwrap();
1414 let input_fqn = m.input_type.as_deref().unwrap_or("");
1415 let input_view = resolver.rust_view_type(input_fqn, package).unwrap();
1416 let input_owned = resolver.rust_type(input_fqn, package).unwrap();
1417 let call_handler = quote! {
1418 let sreq = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(req.reborrow(), req.bytes());
1419 svc.#method_snake(ctx, sreq).await
1420 };
1421 quote! {
1422 .route_view_server_stream::<_, _, #output_type>(
1423 #service_name_const,
1424 #method_name,
1425 ::connectrpc::view_streaming_handler_fn({
1426 let svc = ::std::sync::Arc::clone(&self);
1427 move |ctx, req: ::buffa::view::OwnedView<#input_view<'static>>| {
1428 let svc = ::std::sync::Arc::clone(&svc);
1429 async move {
1430 #call_handler
1433 }
1434 }
1435 }),
1436 )
1437 }
1438 } else if client_streaming && !server_streaming {
1439 let output_type = resolver
1441 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1442 .unwrap();
1443 let into_items = router_stream_items_tokens(resolver, m, package);
1444 quote! {
1445 .route_view_client_stream(
1446 #service_name_const,
1447 #method_name,
1448 ::connectrpc::view_client_streaming_handler_fn({
1449 let svc = ::std::sync::Arc::clone(&self);
1450 move |ctx, req, format| {
1451 let svc = ::std::sync::Arc::clone(&svc);
1452 async move {
1453 #into_items
1454 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1455 }
1456 }
1457 }),
1458 )
1459 }
1460 } else if client_streaming && server_streaming {
1461 let output_type = resolver
1464 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1465 .unwrap();
1466 let into_items = router_stream_items_tokens(resolver, m, package);
1467 quote! {
1468 .route_view_bidi_stream::<_, _, #output_type>(
1469 #service_name_const,
1470 #method_name,
1471 ::connectrpc::view_bidi_streaming_handler_fn({
1472 let svc = ::std::sync::Arc::clone(&self);
1473 move |ctx, req| {
1474 let svc = ::std::sync::Arc::clone(&svc);
1475 async move {
1476 #into_items
1477 svc.#method_snake(ctx, req).await
1478 }
1479 }
1480 }),
1481 )
1482 }
1483 } else {
1484 let is_idempotent = m
1486 .options
1487 .idempotency_level
1488 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1489 .unwrap_or(false);
1490
1491 let route_method = if is_idempotent {
1492 quote! { route_view_idempotent }
1493 } else {
1494 quote! { route_view }
1495 };
1496 let output_type = resolver
1497 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1498 .unwrap();
1499 let input_fqn = m.input_type.as_deref().unwrap_or("");
1503 let input_view = resolver.rust_view_type(input_fqn, package).unwrap();
1504 let input_owned = resolver.rust_type(input_fqn, package).unwrap();
1505 let call_handler = quote! {
1506 let sreq = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(req.reborrow(), req.bytes());
1507 svc.#method_snake(ctx, sreq).await?.encode::<#output_type>(format)
1508 };
1509
1510 quote! {
1511 .#route_method(
1512 #service_name_const,
1513 #method_name,
1514 {
1515 let svc = ::std::sync::Arc::clone(&self);
1516 ::connectrpc::view_handler_fn(move |ctx, req: ::buffa::view::OwnedView<#input_view<'static>>, format| {
1517 let svc = ::std::sync::Arc::clone(&svc);
1518 async move {
1519 #call_handler
1522 }
1523 })
1524 },
1525 )
1526 }
1527 };
1528
1529 quote! {
1530 #route_call
1531 .with_spec(#spec_const)
1532 }
1533 })
1534 .collect();
1535
1536 let client_methods: Vec<TokenStream> = service
1538 .method
1539 .iter()
1540 .map(|m| {
1541 generate_client_method(
1542 &service_name_const,
1543 &full_service_name,
1544 m,
1545 resolver,
1546 package,
1547 )
1548 })
1549 .collect::<Result<Vec<_>>>()?;
1550
1551 let service_server = generate_service_server(
1553 &full_service_name,
1554 &trait_name,
1555 &server_name,
1556 service,
1557 resolver,
1558 package,
1559 )?;
1560
1561 let example_method = service
1563 .method
1564 .first()
1565 .and_then(|m| m.name.as_deref())
1566 .map(|n| make_field_ident(&n.to_snake_case()).to_string())
1567 .unwrap_or_else(|| "method".to_string());
1568
1569 let client_name_str = client_name.to_string();
1571 let client_doc = format!(
1572 r#"Client for this service.
1573
1574Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
1575`Http2Connection` — it has honest `poll_ready` and composes with
1576`tower::balance` for multi-connection load balancing. For **Connect
1577over HTTP/1.1** (or unknown protocol), use `HttpClient`.
1578
1579# Example (gRPC / HTTP/2)
1580
1581```rust,ignore
1582use connectrpc::client::{{Http2Connection, ClientConfig}};
1583use connectrpc::Protocol;
1584
1585let uri: http::Uri = "http://localhost:8080".parse()?;
1586let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
1587let config = ClientConfig::new(uri).with_protocol(Protocol::Grpc);
1588
1589let client = {client_name_str}::new(conn, config);
1590let response = client.{example_method}(request).await?;
1591```
1592
1593# Example (Connect / HTTP/1.1 or ALPN)
1594
1595```rust,ignore
1596use connectrpc::client::{{HttpClient, ClientConfig}};
1597
1598let http = HttpClient::plaintext(); // cleartext http:// only
1599let config = ClientConfig::new("http://localhost:8080".parse()?);
1600
1601let client = {client_name_str}::new(http, config);
1602let response = client.{example_method}(request).await?;
1603```
1604
1605# Working with the response
1606
1607Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
1608[`view()`](::connectrpc::client::UnaryResponse::view) borrows the response
1609message, so field access is zero-copy:
1610
1611```rust,ignore
1612let resp = client.{example_method}(request).await?;
1613let name: &str = resp.view().name; // borrow into the response buffer
1614```
1615
1616If you need the owned struct (e.g. to store or pass by value), use
1617[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
1618
1619```rust,ignore
1620let owned = client.{example_method}(request).await?.into_owned();
1621```
1622
1623[`into_view()`](::connectrpc::client::UnaryResponse::into_view) keeps the
1624zero-copy decoded body (an `OwnedView`) without copying; field access on it
1625goes through `.reborrow()`. Streaming responses yield one
1626[`StreamMessage`](::connectrpc::StreamMessage) per received message from
1627`.message().await` — read fields zero-copy through the generated accessor
1628methods (`msg.name()`) or `.view()`, or convert with `.to_owned_message()`."#
1629 );
1630 let client_doc_tokens = doc_attrs(&client_doc);
1631 let client_cfg_attr: TokenStream = if batch.gate_client_feature {
1639 let feature_name = syn::LitStr::new(batch.client_feature_name(), Span::call_site());
1640 quote! { #[cfg(feature = #feature_name)] }
1641 } else {
1642 TokenStream::new()
1643 };
1644
1645 let spec_consts = generate_spec_consts(&full_service_name, service);
1649
1650 Ok(quote! {
1651 pub const #service_name_const: &str = #full_service_name;
1657
1658 #(#spec_consts)*
1659
1660 #service_doc_tokens
1661 #[allow(clippy::type_complexity)]
1662 pub trait #trait_name: Send + Sync + 'static {
1663 #(#trait_methods)*
1664 }
1665
1666 pub trait #ext_trait_name: #trait_name {
1682 fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router;
1687 }
1688
1689 impl<S: #trait_name> #ext_trait_name for S {
1690 fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router {
1691 router
1692 #(#route_registrations)*
1693 }
1694 }
1695
1696 #[doc(hidden)]
1698 pub struct #register_marker_name;
1699
1700 impl<S: #trait_name> ::connectrpc::ServiceRegister<#register_marker_name>
1701 for ::std::sync::Arc<S>
1702 {
1703 fn register_service(self, router: ::connectrpc::Router) -> ::connectrpc::Router {
1704 <S as #ext_trait_name>::register(self, router)
1705 }
1706 }
1707
1708 #service_server
1709
1710 #client_doc_tokens
1711 #client_cfg_attr
1712 #[derive(Clone)]
1713 pub struct #client_name<T> {
1714 transport: T,
1715 config: ::connectrpc::client::ClientConfig,
1716 }
1717
1718 #client_cfg_attr
1719 impl<T> #client_name<T>
1720 where
1721 T: ::connectrpc::client::ClientTransport,
1722 <T::ResponseBody as ::connectrpc::http_body::Body>::Error: ::std::fmt::Display,
1723 {
1724 pub fn new(transport: T, config: ::connectrpc::client::ClientConfig) -> Self {
1726 Self { transport, config }
1727 }
1728
1729 pub fn config(&self) -> &::connectrpc::client::ClientConfig {
1731 &self.config
1732 }
1733
1734 pub fn config_mut(&mut self) -> &mut ::connectrpc::client::ClientConfig {
1736 &mut self.config
1737 }
1738
1739 #(#client_methods)*
1740 }
1741 })
1742}
1743
1744fn method_spec_const_ident(service: &ServiceDescriptorProto, method_name: &str) -> Ident {
1751 let service_name = service.name.as_deref().unwrap_or("");
1752 format_ident!(
1753 "{}_{}_SPEC",
1754 service_name.to_snake_case().to_uppercase(),
1755 method_name.to_snake_case().to_uppercase()
1756 )
1757}
1758
1759fn generate_spec_consts(
1768 full_service_name: &str,
1769 service: &ServiceDescriptorProto,
1770) -> Vec<TokenStream> {
1771 service
1772 .method
1773 .iter()
1774 .map(|m| {
1775 let method_name = m.name.as_deref().unwrap_or("");
1776 let spec_const = method_spec_const_ident(service, method_name);
1777 let procedure = format!("/{full_service_name}/{method_name}");
1778 let cs = m.client_streaming.unwrap_or(false);
1779 let ss = m.server_streaming.unwrap_or(false);
1780 let stream_type = match (cs, ss) {
1781 (true, true) => quote! { ::connectrpc::StreamType::BidiStream },
1782 (true, false) => quote! { ::connectrpc::StreamType::ClientStream },
1783 (false, true) => quote! { ::connectrpc::StreamType::ServerStream },
1784 (false, false) => quote! { ::connectrpc::StreamType::Unary },
1785 };
1786 let idempotency_level = match m.options.idempotency_level {
1787 Some(IdempotencyLevel::NO_SIDE_EFFECTS) => {
1788 quote! { ::connectrpc::IdempotencyLevel::NoSideEffects }
1789 }
1790 Some(IdempotencyLevel::IDEMPOTENT) => {
1791 quote! { ::connectrpc::IdempotencyLevel::Idempotent }
1792 }
1793 _ => quote! { ::connectrpc::IdempotencyLevel::Unknown },
1794 };
1795 let doc = format!(
1796 "Static [`Spec`](::connectrpc::Spec) for the server-side `{method_name}` RPC.\n\n\
1797 The dispatcher surfaces this on\n\
1798 [`RequestContext::spec`](::connectrpc::RequestContext::spec)."
1799 );
1800 let doc_tokens = doc_attrs(&doc);
1801 quote! {
1802 #doc_tokens
1803 pub const #spec_const: ::connectrpc::Spec =
1804 ::connectrpc::Spec::server(#procedure, #stream_type)
1805 .with_idempotency_level(#idempotency_level);
1806 }
1807 })
1808 .collect()
1809}
1810
1811fn generate_service_server(
1818 full_service_name: &str,
1819 trait_name: &proc_macro2::Ident,
1820 server_name: &proc_macro2::Ident,
1821 service: &ServiceDescriptorProto,
1822 resolver: &TypeResolver<'_>,
1823 package: &str,
1824) -> Result<TokenStream> {
1825 let path_prefix = format!("{full_service_name}/");
1827
1828 let lookup_arms: Vec<TokenStream> = service
1830 .method
1831 .iter()
1832 .map(|m| {
1833 let method_name = m.name.as_deref().unwrap_or("");
1834 let client_streaming = m.client_streaming.unwrap_or(false);
1835 let server_streaming = m.server_streaming.unwrap_or(false);
1836 let is_idempotent = m
1837 .options
1838 .idempotency_level
1839 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1840 .unwrap_or(false);
1841 let spec_const = method_spec_const_ident(service, method_name);
1842
1843 let desc = if client_streaming && server_streaming {
1844 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::bidi_streaming() }
1845 } else if client_streaming {
1846 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::client_streaming() }
1847 } else if server_streaming {
1848 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::server_streaming() }
1849 } else {
1850 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::unary(#is_idempotent) }
1851 };
1852 quote! { #method_name => Some(#desc.with_spec(#spec_const)), }
1853 })
1854 .collect();
1855
1856 let mut call_unary_arms: Vec<TokenStream> = Vec::new();
1861 let mut call_ss_arms: Vec<TokenStream> = Vec::new();
1862 let mut call_cs_arms: Vec<TokenStream> = Vec::new();
1863 let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
1864
1865 for m in &service.method {
1866 let method_name = m.name.as_deref().unwrap_or("");
1867 let method_snake = make_field_ident(&method_name.to_snake_case());
1868 let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
1869 let output_type = resolver.rust_type(m.output_type.as_deref().unwrap_or(""), package)?;
1870 let cs = m.client_streaming.unwrap_or(false);
1871 let ss = m.server_streaming.unwrap_or(false);
1872
1873 let stream_decode = {
1876 let input_fqn = m.input_type.as_deref().unwrap_or("");
1877 let input_owned = resolver.rust_type(input_fqn, package)?;
1878 quote! { ::connectrpc::dispatcher::codegen::decode_message_request_stream::<#input_owned>(requests, format) }
1879 };
1880
1881 if cs && ss {
1882 call_bidi_arms.push(quote! {
1884 #method_name => {
1885 let svc = ::std::sync::Arc::clone(&self.inner);
1886 Box::pin(async move {
1887 let req_stream = #stream_decode;
1888 let resp = svc.#method_snake(ctx, req_stream).await?;
1889 Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1890 })
1891 }
1892 });
1893 } else if cs {
1894 call_cs_arms.push(quote! {
1896 #method_name => {
1897 let svc = ::std::sync::Arc::clone(&self.inner);
1898 Box::pin(async move {
1899 let req_stream = #stream_decode;
1900 svc.#method_snake(ctx, req_stream).await?.encode::<#output_type>(format)
1901 })
1902 }
1903 });
1904 } else if ss {
1905 let input_fqn = m.input_type.as_deref().unwrap_or("");
1907 let input_owned = resolver.rust_type(input_fqn, package)?;
1908 let call_handler = quote! {
1909 let req = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(&req, &body);
1910 let resp = svc.#method_snake(ctx, req).await?;
1911 };
1912 call_ss_arms.push(quote! {
1913 #method_name => {
1914 let svc = ::std::sync::Arc::clone(&self.inner);
1915 Box::pin(async move {
1916 let body = ::connectrpc::dispatcher::codegen::request_proto_bytes::<#input_owned>(request, format)?;
1919 let req: #input_view<'_> = ::connectrpc::dispatcher::codegen::decode_borrowed_request_view(&body)?;
1920 #call_handler
1921 Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1922 })
1923 }
1924 });
1925 } else {
1926 let input_fqn = m.input_type.as_deref().unwrap_or("");
1928 let input_owned = resolver.rust_type(input_fqn, package)?;
1929 let call_handler = quote! {
1930 let req = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(&req, &body);
1931 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1932 };
1933 call_unary_arms.push(quote! {
1934 #method_name => {
1935 let svc = ::std::sync::Arc::clone(&self.inner);
1936 Box::pin(async move {
1937 let body = ::connectrpc::dispatcher::codegen::request_proto_bytes::<#input_owned>(request.encoded()?, format)?;
1944 let req: #input_view<'_> = ::connectrpc::dispatcher::codegen::decode_borrowed_request_view(&body)?;
1945 #call_handler
1946 })
1947 }
1948 });
1949 }
1950 }
1951
1952 let server_doc = format!(
1953 "Monomorphic dispatcher for `{trait_name}`.\n\n\
1954 Unlike `.register(Router)` which type-erases each method into an \
1955 `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
1956 via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
1957 # Example\n\n\
1958 ```rust,ignore\n\
1959 use connectrpc::ConnectRpcService;\n\n\
1960 let server = {server_name}::new(MyImpl);\n\
1961 let service = ConnectRpcService::new(server);\n\
1962 // hand `service` to axum/hyper as a fallback_service\n\
1963 ```"
1964 );
1965 let server_doc_tokens = doc_attrs(&server_doc);
1966
1967 Ok(quote! {
1968 #server_doc_tokens
1969 pub struct #server_name<T> {
1970 inner: ::std::sync::Arc<T>,
1971 }
1972
1973 impl<T: #trait_name> #server_name<T> {
1974 pub fn new(service: T) -> Self {
1976 Self { inner: ::std::sync::Arc::new(service) }
1977 }
1978
1979 pub fn from_arc(inner: ::std::sync::Arc<T>) -> Self {
1981 Self { inner }
1982 }
1983 }
1984
1985 impl<T> Clone for #server_name<T> {
1986 fn clone(&self) -> Self {
1987 Self { inner: ::std::sync::Arc::clone(&self.inner) }
1988 }
1989 }
1990
1991 impl<T: #trait_name> ::connectrpc::Dispatcher for #server_name<T> {
1992 #[inline]
1993 fn lookup(&self, path: &str) -> Option<::connectrpc::dispatcher::codegen::MethodDescriptor> {
1994 let method = path.strip_prefix(#path_prefix)?;
1995 match method {
1996 #(#lookup_arms)*
1997 _ => None,
1998 }
1999 }
2000
2001 fn call_unary(
2002 &self,
2003 path: &str,
2004 ctx: ::connectrpc::RequestContext,
2005 request: ::connectrpc::Payload,
2006 format: ::connectrpc::CodecFormat,
2007 ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
2008 let Some(method) = path.strip_prefix(#path_prefix) else {
2009 return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
2010 };
2011 let _ = (&ctx, &request, &format);
2013 match method {
2014 #(#call_unary_arms)*
2015 _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
2016 }
2017 }
2018
2019 fn call_server_streaming(
2020 &self,
2021 path: &str,
2022 ctx: ::connectrpc::RequestContext,
2023 request: ::buffa::bytes::Bytes,
2024 format: ::connectrpc::CodecFormat,
2025 ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
2026 let Some(method) = path.strip_prefix(#path_prefix) else {
2027 return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
2028 };
2029 let _ = (&ctx, &request, &format);
2030 match method {
2031 #(#call_ss_arms)*
2032 _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
2033 }
2034 }
2035
2036 fn call_client_streaming(
2037 &self,
2038 path: &str,
2039 ctx: ::connectrpc::RequestContext,
2040 requests: ::connectrpc::dispatcher::codegen::RequestStream,
2041 format: ::connectrpc::CodecFormat,
2042 ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
2043 let Some(method) = path.strip_prefix(#path_prefix) else {
2044 return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
2045 };
2046 let _ = (&ctx, &requests, &format);
2047 match method {
2048 #(#call_cs_arms)*
2049 _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
2050 }
2051 }
2052
2053 fn call_bidi_streaming(
2054 &self,
2055 path: &str,
2056 ctx: ::connectrpc::RequestContext,
2057 requests: ::connectrpc::dispatcher::codegen::RequestStream,
2058 format: ::connectrpc::CodecFormat,
2059 ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
2060 let Some(method) = path.strip_prefix(#path_prefix) else {
2061 return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
2062 };
2063 let _ = (&ctx, &requests, &format);
2064 match method {
2065 #(#call_bidi_arms)*
2066 _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
2067 }
2068 }
2069 }
2070 })
2071}
2072
2073fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
2075 let comment = if doc.is_empty() { default } else { doc };
2076 doc_attrs(comment)
2077}
2078
2079fn generate_trait_method(
2081 file: &FileDescriptorProto,
2082 service: &ServiceDescriptorProto,
2083 method: &MethodDescriptorProto,
2084 resolver: &TypeResolver<'_>,
2085 package: &str,
2086) -> Result<TokenStream> {
2087 let method_name = method.name.as_deref().unwrap_or("");
2088 let method_snake = make_field_ident(&method_name.to_snake_case());
2089 let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
2090
2091 let method_doc = get_method_comment(file, service, method).unwrap_or_default();
2093 let method_doc_tokens =
2094 generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
2095
2096 let client_streaming = method.client_streaming.unwrap_or(false);
2098 let server_streaming = method.server_streaming.unwrap_or(false);
2099
2100 let borrow_doc = quote! {
2101 #[doc = ""]
2102 #[doc = " `'a` lets the response body borrow from `&self` (e.g. server-resident state)."]
2103 };
2104
2105 if server_streaming && !client_streaming {
2106 let input_fqn = method.input_type.as_deref().unwrap_or("");
2117 let input_owned = resolver.rust_type(input_fqn, package)?;
2118 let request_param = quote! { ::connectrpc::ServiceRequest<'_, #input_owned> };
2119 let request_doc = quote! {
2120 #[doc = ""]
2121 #[doc = " `request` is borrowed from the request body and is valid for the"]
2122 #[doc = " duration of the call (until the response stream is returned);"]
2123 #[doc = " message fields are read directly on it (zero-copy). Data the"]
2124 #[doc = " returned stream needs must be copied out or converted via"]
2125 #[doc = " `.to_owned_message()`."]
2126 };
2127 Ok(quote! {
2128 #method_doc_tokens
2129 #request_doc
2130 fn #method_snake(
2131 &self,
2132 ctx: ::connectrpc::RequestContext,
2133 request: #request_param,
2134 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
2135 })
2136 } else if client_streaming && !server_streaming {
2137 let stream_owned = stream_owned_message_type(resolver, method, package)?;
2143 let items_doc = stream_items_doc(method);
2144 Ok(quote! {
2145 #method_doc_tokens
2146 #borrow_doc
2147 #items_doc
2148 fn #method_snake<'a>(
2149 &'a self,
2150 ctx: ::connectrpc::RequestContext,
2151 requests: ::connectrpc::InboundStream<#stream_owned>,
2152 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
2153 })
2154 } else if client_streaming && server_streaming {
2155 let stream_owned = stream_owned_message_type(resolver, method, package)?;
2159 let items_doc = stream_items_doc(method);
2160 Ok(quote! {
2161 #method_doc_tokens
2162 #items_doc
2163 fn #method_snake(
2164 &self,
2165 ctx: ::connectrpc::RequestContext,
2166 requests: ::connectrpc::InboundStream<#stream_owned>,
2167 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
2168 })
2169 } else {
2170 let input_fqn = method.input_type.as_deref().unwrap_or("");
2181 let input_owned = resolver.rust_type(input_fqn, package)?;
2182 let request_param = quote! { ::connectrpc::ServiceRequest<'_, #input_owned> };
2183 let request_doc = quote! {
2184 #[doc = ""]
2185 #[doc = " `request` is borrowed from the request body and is valid for the"]
2186 #[doc = " duration of the call; message fields are read directly on it"]
2187 #[doc = " (zero-copy). The response cannot borrow from `request` — use"]
2188 #[doc = " `.to_owned_message()` (or copy the specific fields) for anything"]
2189 #[doc = " returned, stored, or moved into `tokio::spawn`."]
2190 };
2191 Ok(quote! {
2192 #method_doc_tokens
2193 #borrow_doc
2194 #request_doc
2195 fn #method_snake<'a>(
2196 &'a self,
2197 ctx: ::connectrpc::RequestContext,
2198 request: #request_param,
2199 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
2200 })
2201 }
2202}
2203
2204fn generate_client_method(
2215 service_name_const: &Ident,
2216 full_service_name: &str,
2217 method: &MethodDescriptorProto,
2218 resolver: &TypeResolver<'_>,
2219 package: &str,
2220) -> Result<TokenStream> {
2221 let method_name = method.name.as_deref().unwrap_or("");
2222 let method_snake = make_field_ident(&method_name.to_snake_case());
2223 let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
2224 let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
2225 let output_view_type =
2226 resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
2227
2228 let client_streaming = method.client_streaming.unwrap_or(false);
2229 let server_streaming = method.server_streaming.unwrap_or(false);
2230
2231 let doc = format!(
2232 " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
2233 );
2234 let doc_opts = format!(
2235 " Call the {method_name} RPC with explicit per-call options. \
2236 Options override [`ClientConfig`](::connectrpc::client::ClientConfig) defaults."
2237 );
2238
2239 let ret_ty: TokenStream;
2241 let call_body: TokenStream;
2242 let short_args: TokenStream; let opts_args: TokenStream; let short_delegate_args: TokenStream; if client_streaming && !server_streaming {
2247 ret_ty = quote! {
2249 Result<
2250 ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
2251 ::connectrpc::ConnectError,
2252 >
2253 };
2254 call_body = quote! {
2255 ::connectrpc::client::call_client_stream(
2256 &self.transport, &self.config,
2257 #service_name_const, #method_name,
2258 requests, options,
2259 ).await
2260 };
2261 short_args = quote! { requests: impl IntoIterator<Item = #input_type> };
2262 opts_args = quote! { requests: impl IntoIterator<Item = #input_type>, options: ::connectrpc::client::CallOptions };
2263 short_delegate_args = quote! { requests, ::connectrpc::client::CallOptions::default() };
2264 } else if client_streaming && server_streaming {
2265 ret_ty = quote! {
2267 Result<
2268 ::connectrpc::client::BidiStream<
2269 T::ResponseBody, #input_type, #output_view_type<'static>
2270 >,
2271 ::connectrpc::ConnectError,
2272 >
2273 };
2274 call_body = quote! {
2275 ::connectrpc::client::call_bidi_stream(
2276 &self.transport, &self.config,
2277 #service_name_const, #method_name, options,
2278 ).await
2279 };
2280 short_args = quote! {};
2281 opts_args = quote! { options: ::connectrpc::client::CallOptions };
2282 short_delegate_args = quote! { ::connectrpc::client::CallOptions::default() };
2283 } else if server_streaming {
2284 ret_ty = quote! {
2286 Result<
2287 ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
2288 ::connectrpc::ConnectError,
2289 >
2290 };
2291 call_body = quote! {
2292 ::connectrpc::client::call_server_stream(
2293 &self.transport, &self.config,
2294 #service_name_const, #method_name,
2295 request, options,
2296 ).await
2297 };
2298 short_args = quote! { request: #input_type };
2299 opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
2300 short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
2301 } else {
2302 ret_ty = quote! {
2304 Result<
2305 ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
2306 ::connectrpc::ConnectError,
2307 >
2308 };
2309 call_body = quote! {
2310 ::connectrpc::client::call_unary(
2311 &self.transport, &self.config,
2312 #service_name_const, #method_name,
2313 request, options,
2314 ).await
2315 };
2316 short_args = quote! { request: #input_type };
2317 opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
2318 short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
2319 }
2320
2321 Ok(quote! {
2322 #[doc = #doc]
2323 pub async fn #method_snake(&self, #short_args) -> #ret_ty {
2324 self.#method_with_opts(#short_delegate_args).await
2325 }
2326
2327 #[doc = #doc_opts]
2328 pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
2329 #call_body
2330 }
2331 })
2332}
2333
2334fn get_service_comment(
2336 file: &FileDescriptorProto,
2337 service: &ServiceDescriptorProto,
2338) -> Option<String> {
2339 let source_info: &SourceCodeInfo = &file.source_code_info;
2341
2342 let service_index = file.service.iter().position(|s| s.name == service.name)?;
2344
2345 let target_path = vec![6, service_index as i32];
2348
2349 find_comment(source_info, &target_path)
2350}
2351
2352fn get_method_comment(
2354 file: &FileDescriptorProto,
2355 service: &ServiceDescriptorProto,
2356 method: &MethodDescriptorProto,
2357) -> Option<String> {
2358 let source_info: &SourceCodeInfo = &file.source_code_info;
2359
2360 let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
2363 if s.name != service.name {
2364 return None;
2365 }
2366 s.method
2367 .iter()
2368 .position(|m| m.name == method.name)
2369 .map(|mi| (si, mi))
2370 })?;
2371
2372 let target_path = vec![6, service_index as i32, 2, method_index as i32];
2376
2377 find_comment(source_info, &target_path)
2378}
2379
2380fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
2382 for location in &source_info.location {
2383 if location.path == target_path {
2384 let comment = location
2385 .leading_comments
2386 .as_ref()
2387 .or(location.trailing_comments.as_ref())?;
2388
2389 let cleaned: String = comment
2393 .lines()
2394 .map(|line| line.trim())
2395 .filter(|line| !line.is_empty())
2396 .collect::<Vec<_>>()
2397 .join("\n");
2398
2399 if !cleaned.is_empty() {
2400 return Some(cleaned);
2401 }
2402 }
2403 }
2404 None
2405}
2406
2407#[cfg(test)]
2408mod tests {
2409 use super::*;
2410 use buffa_codegen::generated::descriptor::DescriptorProto;
2411 use quote::ToTokens;
2412
2413 #[test]
2414 fn doc_attrs_prefixes_space_for_prettyplease() {
2415 let ts = quote! {
2418 #[allow(dead_code)]
2419 mod m {}
2420 };
2421 let doc = doc_attrs("Hello.\n\nSecond paragraph.");
2422 let combined = quote! { #doc #ts };
2423 let file = syn::parse2::<syn::File>(combined).unwrap();
2424 let out = prettyplease::unparse(&file);
2425 assert!(out.contains("/// Hello."), "got: {out}");
2427 assert!(out.contains("/// Second paragraph."), "got: {out}");
2428 assert!(out.contains("///\n"), "got: {out}");
2430 assert!(!out.contains("///Hello"), "got: {out}");
2432 assert!(!out.contains("/// Hello"), "got: {out}");
2433 }
2434
2435 fn minimal_file(
2440 package: Option<&str>,
2441 input_type: &str,
2442 output_type: &str,
2443 local_messages: &[&str],
2444 ) -> FileDescriptorProto {
2445 minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
2446 }
2447
2448 fn minimal_file_with_method(
2451 package: Option<&str>,
2452 method_name: &str,
2453 input_type: &str,
2454 output_type: &str,
2455 local_messages: &[&str],
2456 ) -> FileDescriptorProto {
2457 let method = MethodDescriptorProto {
2458 name: Some(method_name.into()),
2459 input_type: Some(input_type.into()),
2460 output_type: Some(output_type.into()),
2461 ..Default::default()
2462 };
2463 let service = ServiceDescriptorProto {
2464 name: Some("PingService".into()),
2465 method: vec![method],
2466 ..Default::default()
2467 };
2468 FileDescriptorProto {
2469 name: Some("ping.proto".into()),
2470 package: package.map(|p| p.into()),
2471 service: vec![service],
2472 message_type: local_messages
2473 .iter()
2474 .map(|name| DescriptorProto {
2475 name: Some((*name).into()),
2476 ..Default::default()
2477 })
2478 .collect(),
2479 ..Default::default()
2480 }
2481 }
2482
2483 fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
2487 let methods = method_names
2488 .iter()
2489 .map(|n| MethodDescriptorProto {
2490 name: Some((*n).into()),
2491 input_type: Some(format!(".{package}.Empty")),
2492 output_type: Some(format!(".{package}.Empty")),
2493 ..Default::default()
2494 })
2495 .collect();
2496 let service = ServiceDescriptorProto {
2497 name: Some("PingService".into()),
2498 method: methods,
2499 ..Default::default()
2500 };
2501 FileDescriptorProto {
2502 name: Some("ping.proto".into()),
2503 package: Some(package.into()),
2504 service: vec![service],
2505 message_type: vec![DescriptorProto {
2506 name: Some("Empty".into()),
2507 ..Default::default()
2508 }],
2509 ..Default::default()
2510 }
2511 }
2512
2513 fn gen_service(
2522 files: &[FileDescriptorProto],
2523 target_idx: usize,
2524 extern_paths: &[(String, String)],
2525 require_extern: bool,
2526 ) -> Result<String> {
2527 let mut config = buffa_codegen::CodeGenConfig::default();
2528 config.extern_paths = extern_paths.to_vec();
2529 let target_name = files[target_idx]
2530 .name
2531 .clone()
2532 .into_iter()
2533 .collect::<Vec<_>>();
2534 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2535 let file = &files[target_idx];
2536 let service = &file.service[0];
2537 let batch = BatchState {
2538 colliding_aliases: collect_alias_collisions(files, &target_name),
2539 ..BatchState::default()
2540 };
2541 Ok(generate_service(file, service, &resolver, &batch)?.to_string())
2542 }
2543
2544 fn assert_no_top_level_use(formatted: &str, label: &str) {
2549 let parsed: syn::File = syn::parse_str(formatted).expect("formatted code parses");
2550 let offenders: Vec<String> = parsed
2551 .items
2552 .iter()
2553 .filter_map(|item| match item {
2554 syn::Item::Use(u) => Some(quote!(#u).to_string()),
2555 _ => None,
2556 })
2557 .collect();
2558 assert!(
2559 offenders.is_empty(),
2560 "{label} contains top-level use statement(s): {offenders:?}\nFull source:\n{formatted}"
2561 );
2562 }
2563
2564 fn gen_file(
2565 files: &[FileDescriptorProto],
2566 target_idx: usize,
2567 extern_paths: &[(String, String)],
2568 require_extern: bool,
2569 ) -> Result<String> {
2570 let mut config = buffa_codegen::CodeGenConfig::default();
2571 config.extern_paths = extern_paths.to_vec();
2572 let target_name = files[target_idx]
2573 .name
2574 .clone()
2575 .into_iter()
2576 .collect::<Vec<_>>();
2577 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2578 let mut batch = BatchState {
2579 colliding_aliases: collect_alias_collisions(files, &target_name),
2580 ..BatchState::default()
2581 };
2582 Ok(generate_connect_services(&files[target_idx], &resolver, &mut batch)?.to_string())
2583 }
2584
2585 #[test]
2586 fn unary_response_body_captures_self_lifetime() {
2587 let file = minimal_file(
2588 Some("example.v1"),
2589 ".example.v1.PingReq",
2590 ".example.v1.PingResp",
2591 &["PingReq", "PingResp"],
2592 );
2593 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2594 assert!(code.contains("< 'a >"), "trait method missing 'a: {code}");
2595 assert!(code.contains("& 'a self"), "missing &'a self: {code}");
2596 assert!(
2597 code.contains("use < 'a , Self >"),
2598 "missing use<'a, Self> capture: {code}"
2599 );
2600 assert!(
2601 !code.contains("'static + use"),
2602 "'static bound on body should be dropped: {code}"
2603 );
2604 }
2605
2606 #[test]
2607 fn owned_view_aliases_emitted_for_input_and_output() {
2608 let file = minimal_file(
2609 Some("example.v1"),
2610 ".example.v1.PingReq",
2611 ".example.v1.PingResp",
2612 &["PingReq", "PingResp"],
2613 );
2614 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2615 assert!(
2616 code.contains("pub type OwnedPingReqView = :: buffa :: view :: OwnedView"),
2617 "missing OwnedPingReqView alias: {code}"
2618 );
2619 assert!(
2620 code.contains("pub type OwnedPingRespView = :: buffa :: view :: OwnedView"),
2621 "missing OwnedPingRespView alias: {code}"
2622 );
2623 assert!(
2628 code.contains("request : :: connectrpc :: ServiceRequest < '_"),
2629 "unary trait method should take request: ServiceRequest<'_, PingReq>: {code}"
2630 );
2631 assert!(
2635 !code.contains("impl :: connectrpc :: HasMessageView for"),
2636 "connect-codegen must not emit view-family impls (buffa does): {code}"
2637 );
2638 }
2639
2640 #[test]
2641 fn cross_package_input_collision_suppresses_alias_for_both_sides() {
2642 let v1 = FileDescriptorProto {
2650 name: Some("api/v1/foo/bar/foobar.proto".into()),
2651 package: Some("api.v1.foo.bar".into()),
2652 message_type: vec![DescriptorProto {
2653 name: Some("MyMessage".into()),
2654 ..Default::default()
2655 }],
2656 ..Default::default()
2657 };
2658 let v2 = minimal_file(
2659 Some("api.v2.foo.bar"),
2660 ".api.v1.foo.bar.MyMessage",
2661 ".api.v2.foo.bar.MyMessage",
2662 &["MyMessage"],
2663 );
2664 let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2665
2666 let alias_count = code.matches("pub type OwnedMyMessageView").count();
2669 assert_eq!(
2670 alias_count, 0,
2671 "expected zero OwnedMyMessageView aliases when both sides collide; got {alias_count}: {code}"
2672 );
2673
2674 assert!(
2677 !code.contains("request : OwnedMyMessageView"),
2678 "colliding input must not reference the suppressed alias: {code}"
2679 );
2680 assert!(
2683 code.contains("request : :: connectrpc :: ServiceRequest < '_"),
2684 "colliding unary input should still use ServiceRequest: {code}"
2685 );
2686 }
2687
2688 #[test]
2689 fn cross_package_input_without_collision_keeps_alias() {
2690 let wkt = FileDescriptorProto {
2697 name: Some("google/protobuf/empty.proto".into()),
2698 package: Some("google.protobuf".into()),
2699 message_type: vec![DescriptorProto {
2700 name: Some("Empty".into()),
2701 ..Default::default()
2702 }],
2703 ..Default::default()
2704 };
2705 let svc = minimal_file(
2706 Some("example.v1"),
2707 ".google.protobuf.Empty",
2708 ".example.v1.PingResp",
2709 &["PingResp"],
2710 );
2711 let code = gen_file(&[wkt, svc], 1, &[], false).unwrap();
2712 assert!(
2713 code.contains("pub type OwnedEmptyView = :: buffa :: view :: OwnedView"),
2714 "WKT cross-package input should keep its alias: {code}"
2715 );
2716 assert!(
2722 code.contains(
2723 "request : :: connectrpc :: ServiceRequest < '_ , :: buffa_types :: google :: protobuf :: Empty >"
2724 ),
2725 "extern unary input should use ServiceRequest over the extern owned type: {code}"
2726 );
2727 }
2728
2729 #[test]
2730 fn collision_inlines_in_all_streaming_method_shapes() {
2731 let v1 = FileDescriptorProto {
2737 name: Some("api/v1/foo/bar/foobar.proto".into()),
2738 package: Some("api.v1.foo.bar".into()),
2739 message_type: vec![DescriptorProto {
2740 name: Some("MyMessage".into()),
2741 ..Default::default()
2742 }],
2743 ..Default::default()
2744 };
2745 let v2 = FileDescriptorProto {
2746 name: Some("api/v2/foo/bar/foobar.proto".into()),
2747 package: Some("api.v2.foo.bar".into()),
2748 message_type: vec![DescriptorProto {
2749 name: Some("MyMessage".into()),
2750 ..Default::default()
2751 }],
2752 service: vec![ServiceDescriptorProto {
2753 name: Some("FooBar".into()),
2754 method: vec![
2755 MethodDescriptorProto {
2756 name: Some("Unary".into()),
2757 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2758 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2759 ..Default::default()
2760 },
2761 MethodDescriptorProto {
2762 name: Some("ServerStream".into()),
2763 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2764 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2765 server_streaming: Some(true),
2766 ..Default::default()
2767 },
2768 MethodDescriptorProto {
2769 name: Some("ClientStream".into()),
2770 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2771 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2772 client_streaming: Some(true),
2773 ..Default::default()
2774 },
2775 MethodDescriptorProto {
2776 name: Some("Bidi".into()),
2777 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2778 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2779 client_streaming: Some(true),
2780 server_streaming: Some(true),
2781 ..Default::default()
2782 },
2783 ],
2784 ..Default::default()
2785 }],
2786 ..Default::default()
2787 };
2788 let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2789
2790 assert!(
2792 !code.contains("OwnedMyMessageView"),
2793 "no method shape should reference the suppressed alias: {code}"
2794 );
2795
2796 assert!(
2799 code.matches("request : :: connectrpc :: ServiceRequest < '_")
2800 .count()
2801 >= 2,
2802 "unary and server-streaming should take the borrowed ServiceRequest form: {code}"
2803 );
2804 assert!(
2807 code.matches("requests : :: connectrpc :: InboundStream <")
2808 .count()
2809 >= 2,
2810 "client-streaming and bidi should both take InboundStream items: {code}"
2811 );
2812 }
2813
2814 #[test]
2815 fn streaming_methods_use_encodable_item_type() {
2816 let file = FileDescriptorProto {
2824 name: Some("ex/v1/svc.proto".into()),
2825 package: Some("ex.v1".into()),
2826 message_type: vec![
2827 DescriptorProto {
2828 name: Some("Req".into()),
2829 ..Default::default()
2830 },
2831 DescriptorProto {
2832 name: Some("Resp".into()),
2833 ..Default::default()
2834 },
2835 ],
2836 service: vec![ServiceDescriptorProto {
2837 name: Some("Svc".into()),
2838 method: vec![
2839 MethodDescriptorProto {
2840 name: Some("ServerStream".into()),
2841 input_type: Some(".ex.v1.Req".into()),
2842 output_type: Some(".ex.v1.Resp".into()),
2843 server_streaming: Some(true),
2844 ..Default::default()
2845 },
2846 MethodDescriptorProto {
2847 name: Some("Bidi".into()),
2848 input_type: Some(".ex.v1.Req".into()),
2849 output_type: Some(".ex.v1.Resp".into()),
2850 client_streaming: Some(true),
2851 server_streaming: Some(true),
2852 ..Default::default()
2853 },
2854 ],
2855 ..Default::default()
2856 }],
2857 ..Default::default()
2858 };
2859 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2860
2861 assert_eq!(
2863 code.matches(":: connectrpc :: ServiceStream < impl :: connectrpc :: Encodable < Resp > + Send + use < Self >>")
2864 .count(),
2865 2,
2866 "server-streaming and bidi should both use the Encodable item type: {code}"
2867 );
2868
2869 assert_eq!(
2871 code.matches("encode_response_stream :: < Resp , _ , _ >")
2872 .count(),
2873 2,
2874 "dispatcher arms must turbofish Res to encode_response_stream: {code}"
2875 );
2876
2877 assert!(
2879 code.contains("route_view_server_stream :: < _ , _ , Resp >"),
2880 "route_view_server_stream must turbofish Res: {code}"
2881 );
2882 assert!(
2883 code.contains("route_view_bidi_stream :: < _ , _ , Resp >"),
2884 "route_view_bidi_stream must turbofish Res: {code}"
2885 );
2886 }
2887
2888 #[test]
2889 fn encodable_view_impls_emitted_per_output_type() {
2890 let file = minimal_file(
2891 Some("example.v1"),
2892 ".example.v1.PingReq",
2893 ".example.v1.PingResp",
2894 &["PingReq", "PingResp"],
2895 );
2896 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2897 assert!(
2898 code.contains(
2899 ":: connectrpc :: Encodable < PingResp > for __buffa :: view :: PingRespView"
2900 ),
2901 "missing Encodable<PingResp> for PingRespView: {code}"
2902 );
2903 assert!(
2904 code.contains(
2905 ":: connectrpc :: Encodable < PingResp > for :: buffa :: view :: OwnedView"
2906 ),
2907 "missing Encodable<PingResp> for OwnedView<PingRespView>: {code}"
2908 );
2909 assert!(!code.contains("Encodable < PingReq >"), "got: {code}");
2911 }
2912
2913 #[test]
2914 fn encodable_view_impls_skipped_for_extern_output() {
2915 let wkt = FileDescriptorProto {
2918 name: Some("google/protobuf/empty.proto".into()),
2919 package: Some("google.protobuf".into()),
2920 message_type: vec![DescriptorProto {
2921 name: Some("Empty".into()),
2922 ..Default::default()
2923 }],
2924 ..Default::default()
2925 };
2926 let file = minimal_file(
2927 Some("example.v1"),
2928 ".example.v1.PingReq",
2929 ".google.protobuf.Empty",
2930 &["PingReq"],
2931 );
2932 let code = gen_file(&[wkt, file], 1, &[], false).unwrap();
2933 assert!(
2936 !code.contains("encode_view_body"),
2937 "extern output type must not get Encodable impl: {code}"
2938 );
2939 }
2940
2941 #[test]
2942 fn encodable_view_impls_deduped_across_files() {
2943 let common = FileDescriptorProto {
2948 name: Some("common.proto".into()),
2949 package: Some("common.v1".into()),
2950 message_type: vec![DescriptorProto {
2951 name: Some("Reply".into()),
2952 ..Default::default()
2953 }],
2954 ..Default::default()
2955 };
2956 let svc = |name: &str, pkg: &str| FileDescriptorProto {
2957 name: Some(name.into()),
2958 package: Some(pkg.into()),
2959 message_type: vec![DescriptorProto {
2960 name: Some("Req".into()),
2961 ..Default::default()
2962 }],
2963 service: vec![ServiceDescriptorProto {
2964 name: Some("S".into()),
2965 method: vec![MethodDescriptorProto {
2966 name: Some("Call".into()),
2967 input_type: Some(format!(".{pkg}.Req")),
2968 output_type: Some(".common.v1.Reply".into()),
2969 ..Default::default()
2970 }],
2971 ..Default::default()
2972 }],
2973 ..Default::default()
2974 };
2975 let files = vec![common, svc("a.proto", "a.v1"), svc("b.proto", "b.v1")];
2976
2977 let generated = generate_files(
2978 &files,
2979 &["a.proto".into(), "b.proto".into()],
2980 &Options::default(),
2981 )
2982 .unwrap();
2983
2984 let companions: Vec<_> = generated
2987 .iter()
2988 .filter(|f| f.kind == GeneratedFileKind::Companion)
2989 .collect();
2990 let mut companion_names: Vec<&str> = companions.iter().map(|f| f.name.as_str()).collect();
2991 companion_names.sort_unstable();
2992 assert_eq!(companion_names, ["a.__connect.rs", "b.__connect.rs"]);
2993 for c in &companions {
2994 let stitcher = generated
2995 .iter()
2996 .find(|g| g.kind == GeneratedFileKind::PackageMod && g.package == c.package)
2997 .expect("each companion's package must have a stitcher");
2998 assert!(
2999 stitcher
3000 .content
3001 .contains(&format!("include!(\"{}\")", c.name)),
3002 "stitcher for {} must include companion {}",
3003 c.package,
3004 c.name
3005 );
3006 }
3007
3008 let combined: String = companions.iter().map(|f| f.content.as_str()).collect();
3009
3010 let view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor super::super::common::v1::__buffa::view::ReplyView<'_>";
3011 let owned_view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor ::buffa::view::OwnedView<";
3012 assert_eq!(
3013 combined.matches(view_impl).count(),
3014 1,
3015 "Encodable<Reply> for ReplyView<'_> must appear once: {combined}"
3016 );
3017 assert_eq!(
3018 combined.matches(owned_view_impl).count(),
3019 1,
3020 "Encodable<Reply> for OwnedView<ReplyView> must appear once: {combined}"
3021 );
3022 }
3023
3024 fn file_per_package_fixture() -> Vec<FileDescriptorProto> {
3029 let common = FileDescriptorProto {
3030 name: Some("common.proto".into()),
3031 package: Some("common.v1".into()),
3032 message_type: vec![DescriptorProto {
3033 name: Some("Reply".into()),
3034 ..Default::default()
3035 }],
3036 ..Default::default()
3037 };
3038 let svc = |proto_name: &str, pkg: &str, svc_name: &str, req: &str| FileDescriptorProto {
3043 name: Some(proto_name.into()),
3044 package: Some(pkg.into()),
3045 message_type: vec![DescriptorProto {
3046 name: Some(req.into()),
3047 ..Default::default()
3048 }],
3049 service: vec![ServiceDescriptorProto {
3050 name: Some(svc_name.into()),
3051 method: vec![MethodDescriptorProto {
3052 name: Some("Call".into()),
3053 input_type: Some(format!(".{pkg}.{req}")),
3054 output_type: Some(".common.v1.Reply".into()),
3055 ..Default::default()
3056 }],
3057 ..Default::default()
3058 }],
3059 ..Default::default()
3060 };
3061 vec![
3062 common,
3063 svc("a/x.proto", "a.v1", "XService", "XReq"),
3064 svc("a/y.proto", "a.v1", "YService", "YReq"),
3065 svc("b/z.proto", "b.v1", "ZService", "ZReq"),
3066 ]
3067 }
3068
3069 #[test]
3070 fn generate_files_file_per_package_inlines_companions() {
3071 let files = file_per_package_fixture();
3072 let mut options = Options::default();
3073 options.buffa.file_per_package = true;
3074
3075 let generated = generate_files(
3076 &files,
3077 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3078 &options,
3079 )
3080 .unwrap();
3081
3082 assert!(
3084 !generated
3085 .iter()
3086 .any(|f| f.kind == GeneratedFileKind::Companion),
3087 "file_per_package must not emit sibling Companion files"
3088 );
3089 assert!(
3090 !generated.iter().any(|f| f.name.ends_with(".__connect.rs")),
3091 "file_per_package must not emit `<stem>.__connect.rs` files"
3092 );
3093
3094 let a = generated
3096 .iter()
3097 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "a.v1")
3098 .expect("a.v1 PackageMod must exist");
3099 assert!(
3100 a.content.contains("pub trait XService"),
3101 "a.v1 missing XService"
3102 );
3103 assert!(
3104 a.content.contains("pub trait YService"),
3105 "a.v1 missing YService"
3106 );
3107 assert!(
3108 !a.content.contains("pub trait ZService"),
3109 "a.v1 must not inline ZService"
3110 );
3111 assert!(
3112 !a.content.contains("__connect.rs"),
3113 "a.v1 PackageMod must not include! a connect file: {}",
3114 a.content
3115 );
3116
3117 let b = generated
3118 .iter()
3119 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "b.v1")
3120 .expect("b.v1 PackageMod must exist");
3121 assert!(
3122 b.content.contains("pub trait ZService"),
3123 "b.v1 missing ZService"
3124 );
3125 assert!(
3126 !b.content.contains("pub trait XService"),
3127 "b.v1 must not inline XService"
3128 );
3129
3130 let pkg_mods = generated
3133 .iter()
3134 .filter(|f| f.kind == GeneratedFileKind::PackageMod)
3135 .count();
3136 assert_eq!(
3137 pkg_mods, 2,
3138 "expected exactly two PackageMods: {generated:#?}"
3139 );
3140
3141 let combined: String = generated.iter().map(|f| f.content.as_str()).collect();
3146 assert_eq!(
3147 combined
3148 .matches("impl ::connectrpc::Encodable<super::super::common::v1::Reply>")
3149 .count(),
3150 2,
3151 "Encodable<Reply> impls must be deduplicated across packages \
3152 (1 for ReplyView, 1 for OwnedView<ReplyView>): {combined}"
3153 );
3154 }
3155
3156 #[test]
3157 fn generate_services_file_per_package_emits_one_file_per_package() {
3158 let files = file_per_package_fixture();
3159 let mut options = Options::default();
3160 options.buffa.file_per_package = true;
3161 options
3162 .buffa
3163 .extern_paths
3164 .push((".".into(), "crate::proto".into()));
3165
3166 let generated = generate_services(
3167 &files,
3168 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3169 &options,
3170 )
3171 .unwrap();
3172
3173 assert_eq!(
3176 generated.len(),
3177 2,
3178 "expected exactly two output files: {generated:#?}"
3179 );
3180 assert!(
3181 generated
3182 .iter()
3183 .all(|f| f.kind == GeneratedFileKind::PackageMod),
3184 "all output files must be PackageMod"
3185 );
3186 assert!(
3187 !generated.iter().any(|f| f.name.ends_with(".mod.rs")),
3188 "file_per_package must not emit a separate stitcher"
3189 );
3190 assert!(
3191 !generated.iter().any(|f| f.content.contains("include!")),
3192 "file_per_package output must not include! sibling files"
3193 );
3194
3195 let mut names: Vec<&str> = generated.iter().map(|f| f.name.as_str()).collect();
3196 names.sort_unstable();
3197 assert_eq!(
3198 names,
3199 ["a.v1.rs", "b.v1.rs"],
3200 "filenames must be `<dotted.pkg>.rs` to match buffa's file_per_package convention"
3201 );
3202
3203 let a = generated.iter().find(|f| f.package == "a.v1").unwrap();
3204 assert!(a.content.contains("pub trait XService"));
3205 assert!(a.content.contains("pub trait YService"));
3206 let b = generated.iter().find(|f| f.package == "b.v1").unwrap();
3207 assert!(b.content.contains("pub trait ZService"));
3208 assert!(!b.content.contains("pub trait XService"));
3209 }
3210
3211 #[test]
3212 fn generate_services_file_per_package_default_layout_unchanged() {
3213 let files = file_per_package_fixture();
3216 let mut options = Options::default();
3217 options
3218 .buffa
3219 .extern_paths
3220 .push((".".into(), "crate::proto".into()));
3221
3222 let generated = generate_services(
3223 &files,
3224 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3225 &options,
3226 )
3227 .unwrap();
3228
3229 let mut companions: Vec<&str> = generated
3230 .iter()
3231 .filter(|f| f.kind == GeneratedFileKind::Companion)
3232 .map(|f| f.name.as_str())
3233 .collect();
3234 companions.sort_unstable();
3235 assert_eq!(
3236 companions,
3237 ["a.x.__connect.rs", "a.y.__connect.rs", "b.z.__connect.rs"],
3238 "default layout emits one companion per proto"
3239 );
3240 let mut stitchers: Vec<&str> = generated
3241 .iter()
3242 .filter(|f| f.kind == GeneratedFileKind::PackageMod)
3243 .map(|f| f.name.as_str())
3244 .collect();
3245 stitchers.sort_unstable();
3246 assert_eq!(
3247 stitchers,
3248 ["a.v1.mod.rs", "b.v1.mod.rs"],
3249 "default layout emits one stitcher per package"
3250 );
3251 let a_stitcher = generated.iter().find(|f| f.name == "a.v1.mod.rs").unwrap();
3253 assert!(
3254 a_stitcher
3255 .content
3256 .contains(r#"include!("a.x.__connect.rs");"#)
3257 );
3258 assert!(
3259 a_stitcher
3260 .content
3261 .contains(r#"include!("a.y.__connect.rs");"#)
3262 );
3263 }
3264
3265 #[test]
3266 fn service_name_with_package() {
3267 let file = minimal_file(
3268 Some("example.v1"),
3269 ".example.v1.PingReq",
3270 ".example.v1.PingResp",
3271 &["PingReq", "PingResp"],
3272 );
3273 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3274 assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
3275 }
3276
3277 #[test]
3278 fn service_name_without_package() {
3279 let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
3281 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3282 assert!(code.contains("\"PingService\""), "got: {code}");
3283 assert!(
3284 !code.contains("\".PingService\""),
3285 "must not have leading dot: {code}"
3286 );
3287 }
3288
3289 #[test]
3290 fn same_package_types_use_bare_names() {
3291 let file = minimal_file(
3292 Some("example.v1"),
3293 ".example.v1.PingReq",
3294 ".example.v1.PingResp",
3295 &["PingReq", "PingResp"],
3296 );
3297 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3298 assert!(code.contains("PingReq"), "input type missing: {code}");
3300 assert!(code.contains("PingResp"), "output type missing: {code}");
3301 assert!(
3303 !code.contains("super :: PingReq"),
3304 "unexpected super: {code}"
3305 );
3306 }
3307
3308 #[test]
3309 fn cross_package_types_use_relative_paths() {
3310 let common = FileDescriptorProto {
3314 name: Some("common.proto".into()),
3315 package: Some("common.v1".into()),
3316 message_type: vec![DescriptorProto {
3317 name: Some("Shared".into()),
3318 ..Default::default()
3319 }],
3320 ..Default::default()
3321 };
3322 let svc = minimal_file(
3323 Some("example.v1"),
3324 ".common.v1.Shared",
3325 ".example.v1.Out",
3326 &["Out"],
3327 );
3328 let code = gen_service(&[common, svc], 1, &[], false).unwrap();
3329
3330 assert!(
3333 code.contains("super :: super :: common :: v1 :: Shared"),
3334 "cross-package path not emitted: {code}"
3335 );
3336 assert!(
3337 code.contains("super :: super :: common :: v1 :: __buffa :: view :: SharedView"),
3338 "cross-package view path not emitted: {code}"
3339 );
3340 }
3341
3342 #[test]
3343 fn nested_message_view_type_mirrors_owned_module_nesting() {
3344 let file = FileDescriptorProto {
3349 name: Some("nested.proto".into()),
3350 package: Some("example.v1".into()),
3351 message_type: vec![
3352 DescriptorProto {
3353 name: Some("Outer".into()),
3354 nested_type: vec![DescriptorProto {
3355 name: Some("Inner".into()),
3356 ..Default::default()
3357 }],
3358 ..Default::default()
3359 },
3360 DescriptorProto {
3361 name: Some("Out".into()),
3362 ..Default::default()
3363 },
3364 ],
3365 service: vec![ServiceDescriptorProto {
3366 name: Some("NestedService".into()),
3367 method: vec![MethodDescriptorProto {
3368 name: Some("Ping".into()),
3369 input_type: Some(".example.v1.Outer.Inner".into()),
3370 output_type: Some(".example.v1.Out".into()),
3371 ..Default::default()
3372 }],
3373 ..Default::default()
3374 }],
3375 ..Default::default()
3376 };
3377 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3378
3379 assert!(
3380 code.contains("__buffa :: view :: outer :: InnerView"),
3381 "nested view path not emitted: {code}"
3382 );
3383 assert!(
3384 code.contains("outer :: Inner"),
3385 "nested owned path not emitted: {code}"
3386 );
3387 }
3388
3389 #[test]
3390 fn wkt_types_use_buffa_types_extern_path() {
3391 let wkt = FileDescriptorProto {
3395 name: Some("google/protobuf/empty.proto".into()),
3396 package: Some("google.protobuf".into()),
3397 message_type: vec![DescriptorProto {
3398 name: Some("Empty".into()),
3399 ..Default::default()
3400 }],
3401 ..Default::default()
3402 };
3403 let svc = minimal_file(
3404 Some("example.v1"),
3405 ".google.protobuf.Empty",
3406 ".example.v1.Out",
3407 &["Out"],
3408 );
3409 let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
3410
3411 assert!(
3412 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
3413 "WKT extern path not emitted: {code}"
3414 );
3415 }
3416
3417 #[test]
3418 fn extern_catchall_uses_absolute_paths() {
3419 let file = minimal_file(
3420 Some("example.v1"),
3421 ".example.v1.PingReq",
3422 ".example.v1.PingResp",
3423 &["PingReq", "PingResp"],
3424 );
3425 let extern_paths = [(".".into(), "crate::proto".into())];
3426 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
3427 assert!(
3428 code.contains("crate :: proto :: example :: v1 :: PingReq"),
3429 "owned type path missing: {code}"
3430 );
3431 assert!(
3432 code.contains("crate :: proto :: example :: v1 :: __buffa :: view :: PingReqView"),
3433 "view type path missing: {code}"
3434 );
3435 }
3436
3437 #[test]
3438 fn extern_catchall_with_wkt_longest_wins() {
3439 let wkt = FileDescriptorProto {
3442 name: Some("google/protobuf/empty.proto".into()),
3443 package: Some("google.protobuf".into()),
3444 message_type: vec![DescriptorProto {
3445 name: Some("Empty".into()),
3446 ..Default::default()
3447 }],
3448 ..Default::default()
3449 };
3450 let svc = minimal_file(
3451 Some("example.v1"),
3452 ".google.protobuf.Empty",
3453 ".example.v1.Out",
3454 &["Out"],
3455 );
3456 let extern_paths = [(".".into(), "crate::proto".into())];
3457 let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
3458 assert!(
3459 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
3460 "WKT mapping lost to catch-all: {code}"
3461 );
3462 assert!(
3463 code.contains("crate :: proto :: example :: v1 :: Out"),
3464 "local type not routed through catch-all: {code}"
3465 );
3466 }
3467
3468 #[test]
3469 fn missing_extern_path_errors() {
3470 let file = minimal_file(
3471 Some("example.v1"),
3472 ".example.v1.PingReq",
3473 ".example.v1.PingResp",
3474 &["PingReq", "PingResp"],
3475 );
3476 let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
3477 let msg = err.to_string();
3478 assert!(
3479 msg.contains("extern_path"),
3480 "error message lacks hint: {msg}"
3481 );
3482 }
3483
3484 #[test]
3485 fn keyword_package_escaped() {
3486 let file = minimal_file(
3488 Some("google.type"),
3489 ".google.type.LatLng",
3490 ".google.type.LatLng",
3491 &["LatLng"],
3492 );
3493 let extern_paths = [(".".into(), "crate::proto".into())];
3494 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
3495 assert!(
3496 code.contains("crate :: proto :: google :: r#type :: LatLng"),
3497 "keyword segment not escaped: {code}"
3498 );
3499 }
3500
3501 #[test]
3502 fn keyword_method_escaped() {
3503 let file = minimal_file_with_method(
3506 Some("example.v1"),
3507 "Move",
3508 ".example.v1.Empty",
3509 ".example.v1.Empty",
3510 &["Empty"],
3511 );
3512 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3513 assert!(
3514 code.contains("fn r#move"),
3515 "keyword method not escaped: {code}"
3516 );
3517 assert!(
3518 code.contains("move_with_options"),
3519 "suffixed variant should not need escaping: {code}"
3520 );
3521 assert!(code.contains("client.r#move(request)"));
3523 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3524 }
3525
3526 #[test]
3527 fn path_keyword_method_suffixed() {
3528 let file = minimal_file_with_method(
3531 Some("example.v1"),
3532 "Self",
3533 ".example.v1.Empty",
3534 ".example.v1.Empty",
3535 &["Empty"],
3536 );
3537 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3538 assert!(
3539 code.contains("fn self_"),
3540 "path-keyword method not suffixed: {code}"
3541 );
3542 assert!(code.contains("self_with_options"));
3546 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3547 }
3548
3549 #[test]
3550 fn service_name_keyword_suffixed() {
3551 let mut file = minimal_file(
3555 Some("example.v1"),
3556 ".example.v1.Empty",
3557 ".example.v1.Empty",
3558 &["Empty"],
3559 );
3560 file.service[0].name = Some("Self".into());
3561 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3562 assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
3563 assert!(code.contains("trait SelfExt"));
3564 assert!(code.contains("struct SelfClient"));
3565 assert!(code.contains("struct SelfServer"));
3566 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3567 }
3568
3569 #[test]
3570 fn method_snake_collision_errors() {
3571 let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
3574 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3575 let msg = err.to_string();
3576 assert!(msg.contains("PingService"), "missing service name: {msg}");
3577 assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
3578 assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
3579 assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
3580 }
3581
3582 #[test]
3583 fn method_with_options_collision_errors() {
3584 let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
3587 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3588 let msg = err.to_string();
3589 assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
3590 assert!(
3591 msg.contains("\"PingWithOptions\""),
3592 "missing second method: {msg}"
3593 );
3594 assert!(
3595 msg.contains("`ping_with_options`"),
3596 "missing rust ident: {msg}"
3597 );
3598 }
3599
3600 #[test]
3601 fn distinct_methods_do_not_collide() {
3602 let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
3603 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3604 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3605 }
3606
3607 #[test]
3608 fn options_default_buffa_config() {
3609 let cfg = Options::default().to_buffa_config();
3610 assert!(cfg.generate_json, "connectrpc enables JSON by default");
3611 assert!(cfg.generate_views);
3612 assert!(cfg.emit_register_fn);
3613 assert!(!cfg.strict_utf8_mapping);
3614 }
3615
3616 #[test]
3617 fn options_buffa_passthrough_forces_views() {
3618 let mut opts = Options::default();
3619 opts.buffa.emit_register_fn = false;
3620 opts.buffa.generate_views = false;
3621 let cfg = opts.to_buffa_config();
3622 assert!(!cfg.emit_register_fn);
3623 assert!(cfg.generate_views, "generate_views must be forced on");
3624 }
3625
3626 #[test]
3627 fn generate_files_emit_register_fn_false_suppresses_register_types() {
3628 let file = FileDescriptorProto {
3631 name: Some("ping.proto".into()),
3632 package: Some("example.v1".into()),
3633 message_type: vec![DescriptorProto {
3634 name: Some("PingReq".into()),
3635 ..Default::default()
3636 }],
3637 ..Default::default()
3638 };
3639
3640 let stitcher = |files: &[GeneratedFile]| {
3643 files
3644 .iter()
3645 .find(|f| f.kind == GeneratedFileKind::PackageMod)
3646 .expect("PackageMod file emitted")
3647 .content
3648 .clone()
3649 };
3650
3651 let with_fn = generate_files(
3652 std::slice::from_ref(&file),
3653 &["ping.proto".into()],
3654 &Options::default(),
3655 )
3656 .unwrap();
3657 let mod_rs = stitcher(&with_fn);
3658 assert!(
3659 mod_rs.contains("fn register_types"),
3660 "expected register_types in default output: {mod_rs}"
3661 );
3662
3663 let mut opts = Options::default();
3664 opts.buffa.emit_register_fn = false;
3665 let without_fn =
3666 generate_files(std::slice::from_ref(&file), &["ping.proto".into()], &opts).unwrap();
3667 let mod_rs = stitcher(&without_fn);
3668 assert!(
3669 !mod_rs.contains("fn register_types"),
3670 "register_types should be suppressed: {mod_rs}"
3671 );
3672 }
3673
3674 #[test]
3675 fn plugin_no_register_fn_parses() {
3676 let request = CodeGeneratorRequest {
3677 parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
3678 file_to_generate: vec![],
3679 proto_file: vec![],
3680 ..Default::default()
3681 };
3682 generate(&request).expect("no_register_fn should be a recognized plugin option");
3685 }
3686
3687 fn format_minimal_service(gate_client_feature: bool) -> String {
3692 format_minimal_service_with_client_feature_name(gate_client_feature, "client")
3693 }
3694
3695 fn format_minimal_service_with_client_feature_name(
3696 gate_client_feature: bool,
3697 client_feature_name: &str,
3698 ) -> String {
3699 let file = minimal_file(
3700 Some("example.v1"),
3701 ".example.v1.PingReq",
3702 ".example.v1.PingResp",
3703 &["PingReq", "PingResp"],
3704 );
3705 let config = buffa_codegen::CodeGenConfig::default();
3706 let target = file.name.clone().into_iter().collect::<Vec<_>>();
3707 let resolver = TypeResolver::new(std::slice::from_ref(&file), &target, &config, false);
3708 let service = &file.service[0];
3709 let batch = BatchState {
3710 colliding_aliases: collect_alias_collisions(std::slice::from_ref(&file), &target),
3711 gate_client_feature,
3712 client_feature_name: client_feature_name.to_string(),
3713 ..BatchState::default()
3714 };
3715 format_token_stream(&generate_service(&file, service, &resolver, &batch).unwrap()).unwrap()
3716 }
3717
3718 #[test]
3719 fn default_emission_has_no_client_cfg() {
3720 let out = format_minimal_service(false);
3724 assert!(
3725 !out.contains("#[cfg(feature ="),
3726 "default emission must not emit any cfg attr — external \
3727 consumers should not need to declare a `client` Cargo \
3728 feature unless they explicitly opt in via the \
3729 `gate_client_feature` plugin option:\n{out}"
3730 );
3731 }
3732
3733 #[test]
3734 fn client_items_gated_when_opt_in() {
3735 let out = format_minimal_service(true);
3740 let cfg_count = out.matches("#[cfg(feature = \"client\")]").count();
3741 assert_eq!(
3742 cfg_count, 2,
3743 "expected exactly two #[cfg(feature = \"client\")] attrs (one on \
3744 `pub struct PingServiceClient`, one on its `impl<T>` block); got \
3745 {cfg_count}:\n{out}"
3746 );
3747 }
3748
3749 #[test]
3750 fn client_items_use_custom_feature_name_when_configured() {
3751 let out = format_minimal_service_with_client_feature_name(true, "grpc-client");
3752 let cfg_count = out.matches("#[cfg(feature = \"grpc-client\")]").count();
3753 assert_eq!(
3754 cfg_count, 2,
3755 "expected exactly two #[cfg(feature = \"grpc-client\")] attrs \
3756 (one on `pub struct PingServiceClient`, one on its `impl<T>` \
3757 block); got {cfg_count}:\n{out}"
3758 );
3759 assert!(
3760 !out.contains("#[cfg(feature = \"client\")]"),
3761 "custom feature name must replace the default `client` gate:\n{out}"
3762 );
3763 }
3764
3765 #[test]
3766 fn server_items_never_carry_client_cfg() {
3767 let out = format_minimal_service(true);
3771 for marker in [
3772 "pub trait PingService",
3773 "pub trait PingServiceExt",
3774 "pub struct PingServiceRegisterMarker",
3775 "pub struct PingServiceServer",
3776 "pub const PING_SERVICE_SERVICE_NAME",
3777 ] {
3778 let idx = out
3779 .find(marker)
3780 .unwrap_or_else(|| panic!("expected `{marker}` in output:\n{out}"));
3781 let prefix = &out[..idx];
3782 assert!(
3783 !prefix.trim_end().ends_with("#[cfg(feature = \"client\")]"),
3784 "`{marker}` must not be preceded by a client cfg attr — \
3785 server-side items are always compiled in:\n{out}"
3786 );
3787 }
3788 }
3789
3790 #[test]
3791 fn service_register_impl_and_marker_are_generated() {
3792 let out = format_minimal_service(false);
3793 assert!(
3794 out.contains("pub struct PingServiceRegisterMarker;"),
3795 "generated service must expose an inference marker:\n{out}"
3796 );
3797 assert!(
3798 out.contains(
3799 "impl<S: PingService> ::connectrpc::ServiceRegister<PingServiceRegisterMarker>"
3800 ),
3801 "generated service must implement ServiceRegister for Arc<S>:\n{out}"
3802 );
3803 assert!(
3804 out.contains("for ::std::sync::Arc<S>"),
3805 "ServiceRegister implementation must accept Arc<S>:\n{out}"
3806 );
3807 assert!(
3808 out.contains("fn register_service(self, router: ::connectrpc::Router)"),
3809 "ServiceRegister implementation must expose the bridge method:\n{out}"
3810 );
3811 assert!(
3812 out.contains("<S as PingServiceExt>::register(self, router)"),
3813 "ServiceRegister must forward to the existing extension trait:\n{out}"
3814 );
3815 }
3816
3817 #[test]
3831 fn no_ungated_client_references() {
3832 let out = format_minimal_service(true);
3836 let parsed: syn::File = syn::parse_str(&out).expect("output parses");
3837
3838 let mut offenders: Vec<String> = Vec::new();
3839 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
3840 assert!(
3841 offenders.is_empty(),
3842 "every item that mentions `::connectrpc::client::*` must be \
3843 prefixed with `#[cfg(feature = \"client\")]`. Offenders:\n{}\n\nFull output:\n{out}",
3844 offenders.join("\n")
3845 );
3846 }
3847
3848 fn is_client_feature_cfg(attr: &syn::Attribute) -> bool {
3852 attr.path().is_ident("cfg")
3853 && attr
3854 .to_token_stream()
3855 .to_string()
3856 .contains("feature = \"client\"")
3857 }
3858
3859 fn mentions_connectrpc_client(ts: TokenStream) -> bool {
3864 let rendered = format_token_stream(&ts).unwrap_or_default();
3865 rendered.contains("::connectrpc::client::") || rendered.contains("connectrpc :: client ::")
3866 }
3867
3868 fn scan_items_for_ungated_client_refs(
3885 items: &[syn::Item],
3886 ancestor_gated: bool,
3887 offenders: &mut Vec<String>,
3888 ) {
3889 for item in items {
3890 let (attrs, ident): (&[syn::Attribute], String) = match item {
3896 syn::Item::Struct(s) => (&s.attrs, s.ident.to_string()),
3897 syn::Item::Impl(i) => (
3898 &i.attrs,
3899 format!("impl-block for {}", ToTokens::to_token_stream(&i.self_ty)),
3900 ),
3901 syn::Item::Fn(f) => (&f.attrs, f.sig.ident.to_string()),
3902 syn::Item::Trait(t) => (&t.attrs, t.ident.to_string()),
3903 syn::Item::Const(c) => (&c.attrs, c.ident.to_string()),
3904 syn::Item::Type(t) => (&t.attrs, t.ident.to_string()),
3905 syn::Item::Static(s) => (&s.attrs, s.ident.to_string()),
3906 syn::Item::Use(u) => (&u.attrs, "use-item".to_string()),
3907 syn::Item::ExternCrate(e) => (&e.attrs, e.ident.to_string()),
3908 syn::Item::Macro(m) => (
3909 &m.attrs,
3910 m.ident
3911 .as_ref()
3912 .map(syn::Ident::to_string)
3913 .unwrap_or_else(|| "macro-item".to_string()),
3914 ),
3915 syn::Item::ForeignMod(f) => (&f.attrs, "extern-block".to_string()),
3916 syn::Item::Union(u) => (&u.attrs, u.ident.to_string()),
3917 syn::Item::TraitAlias(t) => (&t.attrs, t.ident.to_string()),
3918 syn::Item::Enum(e) => (&e.attrs, e.ident.to_string()),
3919 syn::Item::Mod(m) => {
3920 let self_gated = m.attrs.iter().any(is_client_feature_cfg);
3921 let gated = ancestor_gated || self_gated;
3922 if let Some((_brace, children)) = &m.content {
3923 scan_items_for_ungated_client_refs(children, gated, offenders);
3924 }
3925 continue;
3928 }
3929 _ => (&[][..], "<unrecognized item>".to_string()),
3933 };
3934 let self_gated = attrs.iter().any(is_client_feature_cfg);
3935 let gated = ancestor_gated || self_gated;
3936 if gated {
3937 continue;
3938 }
3939 if mentions_connectrpc_client(ToTokens::to_token_stream(item)) {
3940 offenders.push(format!(
3941 "ungated reference to ::connectrpc::client in `{ident}`"
3942 ));
3943 }
3944 }
3945 }
3946
3947 #[test]
3952 fn ungated_scanner_handles_nested_modules() {
3953 let parsed: syn::File = syn::parse_str(
3955 r#"
3956 #[cfg(feature = "client")]
3957 pub mod gated_parent {
3958 pub struct WithClientRef {
3959 field: ::connectrpc::client::ClientConfig,
3960 }
3961 }
3962 "#,
3963 )
3964 .unwrap();
3965 let mut offenders = Vec::new();
3966 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
3967 assert!(
3968 offenders.is_empty(),
3969 "parent-level cfg must cover children: {offenders:?}"
3970 );
3971
3972 let parsed: syn::File = syn::parse_str(
3975 r#"
3976 pub mod ungated_parent {
3977 pub struct WithClientRef {
3978 field: ::connectrpc::client::ClientConfig,
3979 }
3980 }
3981 "#,
3982 )
3983 .unwrap();
3984 let mut offenders = Vec::new();
3985 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
3986 assert_eq!(
3987 offenders.len(),
3988 1,
3989 "exactly one offender expected (the inner struct), not the wrapping \
3990 module: {offenders:?}"
3991 );
3992 assert!(
3993 offenders[0].contains("WithClientRef"),
3994 "offender should name the inner struct: {:?}",
3995 offenders[0]
3996 );
3997
3998 let parsed: syn::File = syn::parse_str(
4000 r#"
4001 pub mod outer {
4002 #[cfg(feature = "client")]
4003 pub struct GatedClient {
4004 field: ::connectrpc::client::ClientConfig,
4005 }
4006 }
4007 "#,
4008 )
4009 .unwrap();
4010 let mut offenders = Vec::new();
4011 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4012 assert!(
4013 offenders.is_empty(),
4014 "self-gating child inside ungated module must be OK: {offenders:?}"
4015 );
4016 }
4017
4018 #[test]
4025 fn ungated_scanner_catches_use_and_static_items() {
4026 let parsed: syn::File = syn::parse_str("use ::connectrpc::client::ClientConfig;").unwrap();
4028 let mut offenders = Vec::new();
4029 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4030 assert_eq!(
4031 offenders.len(),
4032 1,
4033 "ungated `use ::connectrpc::client::*` must be flagged: {offenders:?}"
4034 );
4035
4036 let parsed: syn::File =
4038 syn::parse_str("#[cfg(feature = \"client\")] use ::connectrpc::client::ClientConfig;")
4039 .unwrap();
4040 let mut offenders = Vec::new();
4041 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4042 assert!(
4043 offenders.is_empty(),
4044 "gated `use ::connectrpc::client::*` must NOT be flagged: {offenders:?}"
4045 );
4046
4047 let parsed: syn::File =
4049 syn::parse_str("static FOO: &str = stringify!(::connectrpc::client::ClientConfig);")
4050 .unwrap();
4051 let mut offenders = Vec::new();
4052 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4053 assert_eq!(
4054 offenders.len(),
4055 1,
4056 "ungated `static FOO` mentioning ::connectrpc::client must be flagged: \
4057 {offenders:?}"
4058 );
4059 }
4060
4061 #[test]
4062 fn client_cfg_round_trips_through_prettyplease() {
4063 let out = format_minimal_service(true);
4069 assert!(
4072 out.contains("#[cfg(feature = \"client\")]"),
4073 "prettyplease no longer renders the cfg attr as expected; \
4074 update the grep pattern in client_items_always_gated:\n{out}"
4075 );
4076 }
4077
4078 #[test]
4079 fn multi_service_in_one_file_each_client_is_gated() {
4080 let make_service = |name: &str| ServiceDescriptorProto {
4084 name: Some(name.into()),
4085 method: vec![MethodDescriptorProto {
4086 name: Some("Ping".into()),
4087 input_type: Some(".example.v1.PingReq".into()),
4088 output_type: Some(".example.v1.PingResp".into()),
4089 ..Default::default()
4090 }],
4091 ..Default::default()
4092 };
4093 let file = FileDescriptorProto {
4094 name: Some("two.proto".into()),
4095 package: Some("example.v1".into()),
4096 service: vec![make_service("Alpha"), make_service("Beta")],
4097 message_type: vec![
4098 DescriptorProto {
4099 name: Some("PingReq".into()),
4100 ..Default::default()
4101 },
4102 DescriptorProto {
4103 name: Some("PingResp".into()),
4104 ..Default::default()
4105 },
4106 ],
4107 ..Default::default()
4108 };
4109 let config = buffa_codegen::CodeGenConfig::default();
4110 let target = vec!["two.proto".to_string()];
4111 let resolver = TypeResolver::new(std::slice::from_ref(&file), &target, &config, false);
4112 let mut batch = BatchState {
4113 colliding_aliases: collect_alias_collisions(std::slice::from_ref(&file), &target),
4114 gate_client_feature: true,
4115 ..BatchState::default()
4116 };
4117 let ts = generate_connect_services(&file, &resolver, &mut batch).unwrap();
4118 let out = format_token_stream(&ts).unwrap();
4119 let cfg_count = out.matches("#[cfg(feature = \"client\")]").count();
4120 assert_eq!(
4121 cfg_count, 4,
4122 "expected 4 client cfg attrs (2 per service * 2 services); got \
4123 {cfg_count}:\n{out}"
4124 );
4125 for client_struct in ["pub struct AlphaClient", "pub struct BetaClient"] {
4127 let idx = out
4128 .find(client_struct)
4129 .unwrap_or_else(|| panic!("expected `{client_struct}` in output:\n{out}"));
4130 let prefix = &out[..idx];
4131 assert!(
4132 prefix.trim_end().ends_with("#[derive(Clone)]")
4133 || prefix.contains("#[cfg(feature = \"client\")]"),
4134 "`{client_struct}` must have a client cfg attr in its \
4135 attribute cluster:\n{out}"
4136 );
4137 }
4138 }
4139
4140 #[test]
4141 fn plugin_accepts_gate_client_feature_flag() {
4142 let request = CodeGeneratorRequest {
4144 parameter: Some("buffa_module=crate::proto,gate_client_feature".into()),
4145 file_to_generate: vec![],
4146 proto_file: vec![],
4147 ..Default::default()
4148 };
4149 generate(&request).expect("gate_client_feature should be a recognized plugin option");
4150 }
4151
4152 #[test]
4153 fn plugin_accepts_gate_client_feature_value_form() {
4154 let file = minimal_file(
4155 Some("example.v1"),
4156 ".example.v1.PingReq",
4157 ".example.v1.PingResp",
4158 &["PingReq", "PingResp"],
4159 );
4160 let request = CodeGeneratorRequest {
4161 parameter: Some("buffa_module=crate::proto,gate_client_feature=grpc-client".into()),
4162 file_to_generate: vec!["ping.proto".into()],
4163 proto_file: vec![file],
4164 ..Default::default()
4165 };
4166 let response =
4167 generate(&request).expect("custom gate_client_feature value should be recognized");
4168 let connect_file = response
4169 .file
4170 .iter()
4171 .find(|f| f.name.as_deref() == Some("ping.__connect.rs"))
4172 .expect("plugin should emit a connect service companion");
4173 let content = connect_file.content.as_deref().unwrap_or_default();
4174 let cfg_count = content.matches("#[cfg(feature = \"grpc-client\")]").count();
4175 assert_eq!(
4176 cfg_count, 2,
4177 "expected custom feature gate on generated client struct and impl; got \
4178 {cfg_count}:\n{content}"
4179 );
4180 assert!(
4181 !content.contains("#[cfg(feature = \"client\")]"),
4182 "custom plugin feature name must replace the default gate:\n{content}"
4183 );
4184 }
4185
4186 #[test]
4187 fn plugin_rejects_empty_gate_client_feature_value() {
4188 let request = CodeGeneratorRequest {
4189 parameter: Some("buffa_module=crate::proto,gate_client_feature=".into()),
4190 file_to_generate: vec![],
4191 proto_file: vec![],
4192 ..Default::default()
4193 };
4194 let err = generate(&request).expect_err("empty gate_client_feature value must be rejected");
4195 let msg = err.to_string();
4196 assert!(
4197 msg.contains("gate_client_feature requires a non-empty feature name"),
4198 "error should describe the empty feature-name problem: {msg}"
4199 );
4200 }
4201
4202 #[test]
4203 fn options_reject_invalid_client_feature_name() {
4204 let opts = Options {
4205 gate_client_feature: true,
4206 client_feature_name: "grpc client".into(),
4207 ..Options::default()
4208 };
4209 let err = generate_services(&[], &[], &opts)
4210 .expect_err("invalid client feature name must be rejected");
4211 assert!(
4212 err.to_string().contains("not a valid Cargo feature name"),
4213 "error should name the grammar problem: {err}"
4214 );
4215 }
4216
4217 fn all_messages_options(extra_extern: &[(&str, &str)]) -> Options {
4221 let mut options = Options {
4222 encodable_impls: EncodableImpls::AllMessages,
4223 ..Options::default()
4224 };
4225 for (proto, rust) in extra_extern {
4226 options
4227 .buffa
4228 .extern_paths
4229 .push(((*proto).into(), (*rust).into()));
4230 }
4231 options
4232 .buffa
4233 .extern_paths
4234 .push((".".into(), "crate::proto".into()));
4235 options
4236 }
4237
4238 #[test]
4239 fn all_messages_emits_impls_for_serviceless_proto() {
4240 let file = FileDescriptorProto {
4241 name: Some("common.proto".into()),
4242 package: Some("common.v1".into()),
4243 message_type: vec![DescriptorProto {
4244 name: Some("Shared".into()),
4245 ..Default::default()
4246 }],
4247 ..Default::default()
4248 };
4249 let generated = generate_services(
4250 std::slice::from_ref(&file),
4251 &["common.proto".into()],
4252 &all_messages_options(&[]),
4253 )
4254 .unwrap();
4255
4256 let companion = generated
4257 .iter()
4258 .find(|f| f.name == "common.__connect.rs")
4259 .expect("service-less proto must get a companion under all_messages");
4260 assert_eq!(
4261 companion
4262 .content
4263 .matches("impl ::connectrpc::Encodable<")
4264 .count(),
4265 2,
4266 "one view + one OwnedView impl: {}",
4267 companion.content
4268 );
4269 assert!(
4270 companion.content.contains("SharedView"),
4271 "impls must target the view type: {}",
4272 companion.content
4273 );
4274 let stitcher = generated
4276 .iter()
4277 .find(|f| f.kind == GeneratedFileKind::PackageMod)
4278 .expect("package stitcher for the companion");
4279 assert!(
4280 stitcher
4281 .content
4282 .contains("include!(\"common.__connect.rs\")"),
4283 "stitcher must include the companion: {}",
4284 stitcher.content
4285 );
4286 }
4287
4288 #[test]
4289 fn all_messages_skips_extern_mapped_proto_entirely() {
4290 let file = FileDescriptorProto {
4293 name: Some("common.proto".into()),
4294 package: Some("common.v1".into()),
4295 message_type: vec![DescriptorProto {
4296 name: Some("Shared".into()),
4297 ..Default::default()
4298 }],
4299 ..Default::default()
4300 };
4301 let generated = generate_services(
4302 std::slice::from_ref(&file),
4303 &["common.proto".into()],
4304 &all_messages_options(&[(".common.v1", "::common_protos::proto::common::v1")]),
4305 )
4306 .unwrap();
4307 assert!(
4308 generated.is_empty(),
4309 "foreign-mapped proto must produce no files: {:?}",
4310 generated.iter().map(|f| &f.name).collect::<Vec<_>>()
4311 );
4312 }
4313
4314 #[test]
4315 fn all_messages_dedups_with_service_output_impls() {
4316 let file = minimal_file(
4320 Some("example.v1"),
4321 ".example.v1.PingReq",
4322 ".example.v1.PingResp",
4323 &["PingReq", "PingResp"],
4324 );
4325 let generated = generate_services(
4326 std::slice::from_ref(&file),
4327 &["ping.proto".into()],
4328 &all_messages_options(&[]),
4329 )
4330 .unwrap();
4331 let companion = generated
4332 .iter()
4333 .find(|f| f.name == "ping.__connect.rs")
4334 .expect("service companion");
4335 assert_eq!(
4339 companion.content.matches("encode_view_body").count(),
4340 4,
4341 "exactly one impl pair per message (PingReq + PingResp), \
4342 no E0119 duplicates: {}",
4343 companion.content
4344 );
4345 assert!(companion.content.contains("PingReqView"));
4346 assert!(companion.content.contains("PingRespView"));
4347 }
4348
4349 #[test]
4350 fn all_messages_recurses_nested_and_skips_map_entries() {
4351 use buffa_codegen::generated::descriptor::MessageOptions;
4352 let file = FileDescriptorProto {
4353 name: Some("nested.proto".into()),
4354 package: Some("example.v1".into()),
4355 message_type: vec![DescriptorProto {
4356 name: Some("Outer".into()),
4357 nested_type: vec![
4358 DescriptorProto {
4359 name: Some("Inner".into()),
4360 ..Default::default()
4361 },
4362 DescriptorProto {
4363 name: Some("LabelsEntry".into()),
4364 options: buffa::MessageField::some(MessageOptions {
4365 map_entry: Some(true),
4366 ..Default::default()
4367 }),
4368 ..Default::default()
4369 },
4370 ],
4371 ..Default::default()
4372 }],
4373 ..Default::default()
4374 };
4375 let generated = generate_services(
4376 std::slice::from_ref(&file),
4377 &["nested.proto".into()],
4378 &all_messages_options(&[]),
4379 )
4380 .unwrap();
4381 let companion = generated
4382 .iter()
4383 .find(|f| f.name == "nested.__connect.rs")
4384 .expect("companion with nested-message impls");
4385 assert_eq!(
4386 companion
4387 .content
4388 .matches("impl ::connectrpc::Encodable<")
4389 .count(),
4390 4,
4391 "impl pairs for Outer and Outer.Inner only: {}",
4392 companion.content
4393 );
4394 assert!(
4395 companion.content.contains("InnerView"),
4396 "nested message must get impls: {}",
4397 companion.content
4398 );
4399 assert!(
4400 !companion.content.contains("LabelsEntry"),
4401 "synthetic map entries must not get impls: {}",
4402 companion.content
4403 );
4404 }
4405
4406 #[test]
4407 fn all_messages_file_per_package_collapses_serviceless_output() {
4408 let file = FileDescriptorProto {
4412 name: Some("common.proto".into()),
4413 package: Some("common.v1".into()),
4414 message_type: vec![DescriptorProto {
4415 name: Some("Shared".into()),
4416 ..Default::default()
4417 }],
4418 ..Default::default()
4419 };
4420 let mut options = all_messages_options(&[]);
4421 options.buffa.file_per_package = true;
4422 let generated = generate_services(
4423 std::slice::from_ref(&file),
4424 &["common.proto".into()],
4425 &options,
4426 )
4427 .unwrap();
4428 assert_eq!(generated.len(), 1, "exactly one PackageMod file");
4429 let pkg_mod = &generated[0];
4430 assert_eq!(pkg_mod.kind, GeneratedFileKind::PackageMod);
4431 assert_eq!(pkg_mod.name, "common.v1.rs");
4432 assert_eq!(
4433 pkg_mod.content.matches("encode_view_body").count(),
4434 2,
4435 "impl pair inlined into the package file: {}",
4436 pkg_mod.content
4437 );
4438 }
4439
4440 #[test]
4441 fn generate_files_all_messages_file_per_package_inlines_serviceless_impls() {
4442 let file = FileDescriptorProto {
4447 name: Some("common.proto".into()),
4448 package: Some("common.v1".into()),
4449 message_type: vec![DescriptorProto {
4450 name: Some("Shared".into()),
4451 ..Default::default()
4452 }],
4453 ..Default::default()
4454 };
4455 let mut options = Options {
4456 encodable_impls: EncodableImpls::AllMessages,
4457 ..Options::default()
4458 };
4459 options.buffa.file_per_package = true;
4460 let generated = generate_files(
4461 std::slice::from_ref(&file),
4462 &["common.proto".into()],
4463 &options,
4464 )
4465 .unwrap();
4466 assert!(
4467 !generated
4468 .iter()
4469 .any(|f| f.kind == GeneratedFileKind::Companion),
4470 "file_per_package must not leave sibling Companion files"
4471 );
4472 let pkg_mod = generated
4473 .iter()
4474 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "common.v1")
4475 .expect("PackageMod for common.v1");
4476 assert_eq!(
4477 pkg_mod.content.matches("encode_view_body").count(),
4478 2,
4479 "impl pair inlined into the package file: {}",
4480 pkg_mod.content
4481 );
4482 }
4483
4484 #[test]
4485 fn plugin_accepts_encodable_impls_option() {
4486 let file = FileDescriptorProto {
4487 name: Some("common.proto".into()),
4488 package: Some("common.v1".into()),
4489 message_type: vec![DescriptorProto {
4490 name: Some("Shared".into()),
4491 ..Default::default()
4492 }],
4493 ..Default::default()
4494 };
4495 let request = CodeGeneratorRequest {
4496 parameter: Some("buffa_module=crate::proto,encodable_impls=all_messages".into()),
4497 file_to_generate: vec!["common.proto".into()],
4498 proto_file: vec![file],
4499 ..Default::default()
4500 };
4501 let response = generate(&request).expect("encodable_impls=all_messages is recognized");
4502 let companion = response
4503 .file
4504 .iter()
4505 .find(|f| f.name.as_deref() == Some("common.__connect.rs"))
4506 .expect("plugin should emit impls for a service-less proto");
4507 assert!(
4508 companion
4509 .content
4510 .as_deref()
4511 .unwrap_or_default()
4512 .contains("impl ::connectrpc::Encodable<"),
4513 );
4514 }
4515
4516 #[test]
4517 fn plugin_rejects_invalid_encodable_impls_value() {
4518 let request = CodeGeneratorRequest {
4519 parameter: Some("buffa_module=crate::proto,encodable_impls=bogus".into()),
4520 file_to_generate: vec![],
4521 proto_file: vec![],
4522 ..Default::default()
4523 };
4524 let err = generate(&request).expect_err("bogus encodable_impls value must be rejected");
4525 let msg = err.to_string();
4526 assert!(
4527 msg.contains("invalid encodable_impls value"),
4528 "error should describe the bad value: {msg}"
4529 );
4530 }
4531
4532 #[test]
4533 fn generate_files_all_messages_wires_serviceless_companion() {
4534 let file = FileDescriptorProto {
4537 name: Some("common.proto".into()),
4538 package: Some("common.v1".into()),
4539 message_type: vec![DescriptorProto {
4540 name: Some("Shared".into()),
4541 ..Default::default()
4542 }],
4543 ..Default::default()
4544 };
4545 let options = Options {
4546 encodable_impls: EncodableImpls::AllMessages,
4547 ..Options::default()
4548 };
4549 let generated = generate_files(
4550 std::slice::from_ref(&file),
4551 &["common.proto".into()],
4552 &options,
4553 )
4554 .unwrap();
4555 let companion = generated
4556 .iter()
4557 .find(|f| f.kind == GeneratedFileKind::Companion)
4558 .expect("companion for service-less proto in unified mode");
4559 assert_eq!(
4560 companion
4561 .content
4562 .matches("impl ::connectrpc::Encodable<")
4563 .count(),
4564 2
4565 );
4566 let stitcher = generated
4567 .iter()
4568 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "common.v1")
4569 .expect("package stitcher");
4570 assert!(
4571 stitcher
4572 .content
4573 .contains(&format!("include!(\"{}\")", companion.name)),
4574 "stitcher must include the companion: {}",
4575 stitcher.content
4576 );
4577 }
4578
4579 #[test]
4580 fn plugin_rejects_old_client_feature_value_form() {
4581 let request = CodeGeneratorRequest {
4587 parameter: Some("buffa_module=crate::proto,client_feature=client".into()),
4588 file_to_generate: vec![],
4589 proto_file: vec![],
4590 ..Default::default()
4591 };
4592 let err = generate(&request)
4593 .expect_err("legacy `client_feature=…` option must now fail as unknown");
4594 let msg = err.to_string();
4595 assert!(
4596 msg.contains("client_feature"),
4597 "error should name the offending option: {msg}"
4598 );
4599 assert!(
4600 msg.contains("unknown plugin option"),
4601 "error should say the option is unknown: {msg}"
4602 );
4603 }
4604
4605 #[test]
4606 fn plugin_file_per_package_collapses_output() {
4607 let request = CodeGeneratorRequest {
4610 parameter: Some("buffa_module=crate::proto,file_per_package".into()),
4611 file_to_generate: vec!["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
4612 proto_file: file_per_package_fixture(),
4613 ..Default::default()
4614 };
4615 let response = generate(&request).expect("file_per_package should parse and generate");
4616 let mut names: Vec<&str> = response
4617 .file
4618 .iter()
4619 .filter_map(|f| f.name.as_deref())
4620 .collect();
4621 names.sort_unstable();
4622 assert_eq!(
4623 names,
4624 ["a.v1.rs", "b.v1.rs"],
4625 "expected one file per package: {names:?}"
4626 );
4627 for f in &response.file {
4628 let content = f.content.as_deref().unwrap_or_default();
4629 assert!(
4630 !content.contains("include!"),
4631 "file_per_package output must be self-contained: {content}"
4632 );
4633 }
4634 }
4635
4636 #[test]
4637 fn no_top_level_use_statements_in_generated_code() {
4638 let file = minimal_file(
4642 Some("example.v1"),
4643 ".example.v1.PingReq",
4644 ".example.v1.PingResp",
4645 &["PingReq", "PingResp"],
4646 );
4647 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
4648 let formatted = format_token_stream(&code.parse::<TokenStream>().unwrap()).unwrap();
4649 assert_no_top_level_use(&formatted, "generated code");
4650 }
4651
4652 #[test]
4653 fn multi_service_include_no_e0252() {
4654 let file_a = {
4657 let method = MethodDescriptorProto {
4658 name: Some("Ping".into()),
4659 input_type: Some(".svc.v1.PingReq".into()),
4660 output_type: Some(".svc.v1.PingResp".into()),
4661 ..Default::default()
4662 };
4663 let service = ServiceDescriptorProto {
4664 name: Some("Alpha".into()),
4665 method: vec![method],
4666 ..Default::default()
4667 };
4668 FileDescriptorProto {
4669 name: Some("alpha.proto".into()),
4670 package: Some("svc.v1".into()),
4671 service: vec![service],
4672 message_type: vec![
4673 DescriptorProto {
4674 name: Some("PingReq".into()),
4675 ..Default::default()
4676 },
4677 DescriptorProto {
4678 name: Some("PingResp".into()),
4679 ..Default::default()
4680 },
4681 ],
4682 ..Default::default()
4683 }
4684 };
4685 let file_b = {
4686 let method = MethodDescriptorProto {
4687 name: Some("Pong".into()),
4688 input_type: Some(".svc.v1.PongReq".into()),
4689 output_type: Some(".svc.v1.PongResp".into()),
4690 ..Default::default()
4691 };
4692 let service = ServiceDescriptorProto {
4693 name: Some("Beta".into()),
4694 method: vec![method],
4695 ..Default::default()
4696 };
4697 FileDescriptorProto {
4698 name: Some("beta.proto".into()),
4699 package: Some("svc.v1".into()),
4700 service: vec![service],
4701 message_type: vec![
4702 DescriptorProto {
4703 name: Some("PongReq".into()),
4704 ..Default::default()
4705 },
4706 DescriptorProto {
4707 name: Some("PongResp".into()),
4708 ..Default::default()
4709 },
4710 ],
4711 ..Default::default()
4712 }
4713 };
4714
4715 let files = vec![file_a, file_b];
4716 let config = buffa_codegen::CodeGenConfig::default();
4717 let targets = vec!["alpha.proto".to_string(), "beta.proto".to_string()];
4718 let resolver = TypeResolver::new(&files, &targets, &config, false);
4719
4720 let mut batch = BatchState {
4721 colliding_aliases: collect_alias_collisions(&files, &targets),
4722 ..BatchState::default()
4723 };
4724 let code_a = generate_connect_services(&files[0], &resolver, &mut batch).unwrap();
4725 let code_b = generate_connect_services(&files[1], &resolver, &mut batch).unwrap();
4726
4727 let formatted_a = format_token_stream(&code_a).unwrap();
4728 let formatted_b = format_token_stream(&code_b).unwrap();
4729
4730 syn::parse_str::<syn::File>(&formatted_a).expect("service A should parse independently");
4732 syn::parse_str::<syn::File>(&formatted_b).expect("service B should parse independently");
4733
4734 let combined = format!("{formatted_a}\n{formatted_b}");
4736 syn::parse_str::<syn::File>(&combined)
4737 .expect("combined services should parse without E0252");
4738
4739 assert_no_top_level_use(&formatted_a, "service A");
4741 assert_no_top_level_use(&formatted_b, "service B");
4742 }
4743
4744 #[test]
4748 fn generate_spec_consts_per_method() {
4749 use buffa_codegen::generated::descriptor::MethodOptions;
4750
4751 let m = |name: &str, cs: bool, ss: bool, idem: Option<IdempotencyLevel>| {
4752 MethodDescriptorProto {
4753 name: Some(name.into()),
4754 input_type: Some(".pkg.Req".into()),
4755 output_type: Some(".pkg.Resp".into()),
4756 client_streaming: Some(cs),
4757 server_streaming: Some(ss),
4758 options: MethodOptions {
4759 idempotency_level: idem,
4760 ..Default::default()
4761 }
4762 .into(),
4763 ..Default::default()
4764 }
4765 };
4766 let service = ServiceDescriptorProto {
4767 name: Some("EchoService".into()),
4768 method: vec![
4769 m("Say", false, false, Some(IdempotencyLevel::NO_SIDE_EFFECTS)),
4770 m("Subscribe", false, true, Some(IdempotencyLevel::IDEMPOTENT)),
4771 m("Upload", true, false, None),
4772 m("Chat", true, true, None),
4773 ],
4774 ..Default::default()
4775 };
4776
4777 assert_eq!(
4779 method_spec_const_ident(&service, "Say").to_string(),
4780 "ECHO_SERVICE_SAY_SPEC"
4781 );
4782
4783 let consts = generate_spec_consts("pkg.EchoService", &service);
4784 assert_eq!(consts.len(), 4, "one const per method");
4785
4786 let render = |ts: &TokenStream| {
4787 let file = syn::parse2::<syn::File>(ts.clone()).expect("const should parse");
4788 prettyplease::unparse(&file)
4789 };
4790 let say = render(&consts[0]);
4791 assert!(say.contains("pub const ECHO_SERVICE_SAY_SPEC"), "{say}");
4792 assert!(say.contains(r#""/pkg.EchoService/Say""#), "{say}");
4793 assert!(say.contains("StreamType::Unary"), "{say}");
4794 assert!(say.contains("IdempotencyLevel::NoSideEffects"), "{say}");
4795
4796 let subscribe = render(&consts[1]);
4797 assert!(
4798 subscribe.contains("StreamType::ServerStream"),
4799 "{subscribe}"
4800 );
4801 assert!(
4802 subscribe.contains("IdempotencyLevel::Idempotent"),
4803 "{subscribe}"
4804 );
4805
4806 let upload = render(&consts[2]);
4807 assert!(upload.contains("StreamType::ClientStream"), "{upload}");
4808 assert!(upload.contains("IdempotencyLevel::Unknown"), "{upload}");
4809
4810 let chat = render(&consts[3]);
4811 assert!(chat.contains("StreamType::BidiStream"), "{chat}");
4812 }
4813}