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::Edition;
22use buffa_codegen::generated::descriptor::FileDescriptorProto;
23use buffa_codegen::generated::descriptor::MethodDescriptorProto;
24use buffa_codegen::generated::descriptor::ServiceDescriptorProto;
25use buffa_codegen::generated::descriptor::SourceCodeInfo;
26use buffa_codegen::generated::descriptor::method_options::IdempotencyLevel;
27use buffa_codegen::idents::make_field_ident;
28use buffa_codegen::idents::rust_path_to_tokens;
29
30pub use buffa_codegen::generated::descriptor;
31pub use buffa_codegen::{CodeGenConfig, GeneratedFile, GeneratedFileKind};
32
33use crate::plugin::CodeGeneratorRequest;
34use crate::plugin::CodeGeneratorResponse;
35use crate::plugin::CodeGeneratorResponseFile;
36
37#[derive(Debug, Clone)]
46#[non_exhaustive]
47pub struct Options {
48 pub buffa: CodeGenConfig,
71
72 pub gate_client_feature: bool,
77
78 pub client_feature_name: String,
81
82 pub encodable_impls: EncodableImpls,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120#[non_exhaustive]
121pub enum EncodableImpls {
122 #[default]
125 Outputs,
126 AllMessages,
129}
130
131impl Default for Options {
132 fn default() -> Self {
133 let mut buffa = CodeGenConfig::default();
134 buffa.generate_json = true;
135 Self {
136 buffa,
137 gate_client_feature: false,
138 client_feature_name: "client".into(),
139 encodable_impls: EncodableImpls::default(),
140 }
141 }
142}
143
144impl Options {
145 fn to_buffa_config(&self) -> CodeGenConfig {
148 let mut config = self.buffa.clone();
149 config.generate_views = true;
150 config
151 }
152
153 fn client_feature_name(&self) -> Result<&str> {
154 let name = self.client_feature_name.trim();
155 if name.is_empty() {
156 if self.gate_client_feature {
157 anyhow::bail!("client feature name must not be empty");
158 }
159 return Ok("client");
160 }
161 if !buffa_codegen::FeatureGateNames::is_valid_name(name) {
162 anyhow::bail!(
163 "client feature name {name:?} is not a valid Cargo feature name \
164 (must start with an alphanumeric or `_` and contain only \
165 alphanumerics, `_`, `-`, `+`, `.`)"
166 );
167 }
168 Ok(name)
169 }
170}
171
172fn emit_service_files(
178 proto_file: &[FileDescriptorProto],
179 file_to_generate: &[String],
180 resolver: &TypeResolver<'_>,
181 options: &Options,
182 client_feature_name: &str,
183) -> Result<Vec<GeneratedFile>> {
184 let mut out = Vec::new();
185 let mut batch = BatchState {
193 colliding_aliases: collect_alias_collisions(proto_file, file_to_generate),
194 gate_client_feature: options.gate_client_feature,
195 client_feature_name: client_feature_name.to_string(),
196 all_message_encodable_impls: options.encodable_impls == EncodableImpls::AllMessages,
197 ..BatchState::default()
198 };
199 for file_name in file_to_generate {
200 let file_desc = proto_file
201 .iter()
202 .find(|f| f.name.as_deref() == Some(file_name.as_str()));
203
204 if let Some(file) = file_desc
205 && (!file.service.is_empty()
206 || (batch.all_message_encodable_impls && !file.message_type.is_empty()))
207 {
208 let service_tokens = generate_connect_services(file, resolver, &mut batch)?;
209 if service_tokens.is_empty() {
210 debug_assert!(
217 file.service.is_empty(),
218 "service-declaring proto {file_name} produced no service tokens"
219 );
220 continue;
221 }
222 let service_code = format_token_stream(&service_tokens)?;
223 out.push(GeneratedFile {
231 name: format!(
232 "{}.__connect.rs",
233 buffa_codegen::proto_path_to_stem(file_name)
234 ),
235 package: file.package.clone().unwrap_or_default(),
236 kind: GeneratedFileKind::Companion,
237 content: service_code,
238 });
239 }
240 }
241 Ok(out)
242}
243
244pub fn generate_files(
275 proto_file: &[FileDescriptorProto],
276 file_to_generate: &[String],
277 options: &Options,
278) -> Result<Vec<GeneratedFile>> {
279 let config = options.to_buffa_config();
280
281 let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
282 .map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
283
284 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
285 let client_feature_name = options.client_feature_name()?;
286 let service_files = emit_service_files(
287 proto_file,
288 file_to_generate,
289 &resolver,
290 options,
291 client_feature_name,
292 )?;
293
294 if config.file_per_package {
295 inline_companions_into_package_mods(&mut files, service_files);
303 } else {
304 buffa_codegen::apply_companions(&mut files, service_files);
311
312 debug_assert!(
319 files.iter().all(|f| {
320 f.kind != GeneratedFileKind::Companion
321 || files.iter().any(|g| {
322 g.kind == GeneratedFileKind::PackageMod
323 && g.content.contains(&format!("include!(\"{}\")", f.name))
324 })
325 }),
326 "a companion service file was not wired into any package stitcher"
327 );
328 }
329
330 Ok(files)
331}
332
333fn inline_companions_into_package_mods(
352 files: &mut [GeneratedFile],
355 companions: Vec<GeneratedFile>,
356) {
357 debug_assert!(
361 companions.iter().all(|c| files
362 .iter()
363 .any(|f| f.kind == GeneratedFileKind::PackageMod && f.package == c.package)),
364 "a companion service file's package has no PackageMod to inline into"
365 );
366 for comp in companions {
367 if let Some(pkg_mod) = files
368 .iter_mut()
369 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == comp.package)
370 {
371 pkg_mod.content.push('\n');
372 pkg_mod.content.push_str(&comp.content);
373 }
374 }
375}
376
377pub fn generate_services(
414 proto_file: &[FileDescriptorProto],
415 file_to_generate: &[String],
416 options: &Options,
417) -> Result<Vec<GeneratedFile>> {
418 use std::collections::BTreeMap;
419
420 let config = options.to_buffa_config();
421 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
422 let client_feature_name = options.client_feature_name()?;
423 let mut files = emit_service_files(
424 proto_file,
425 file_to_generate,
426 &resolver,
427 options,
428 client_feature_name,
429 )?;
430
431 if config.file_per_package {
432 let mut by_package: BTreeMap<String, String> = BTreeMap::new();
437 for f in files {
438 let entry = by_package.entry(f.package).or_insert_with(|| {
439 String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n")
440 });
441 entry.push('\n');
442 entry.push_str(&f.content);
443 }
444 return Ok(by_package
445 .into_iter()
446 .map(|(package, content)| GeneratedFile {
447 name: buffa_codegen::package_to_filename(&package),
448 package,
449 kind: GeneratedFileKind::PackageMod,
450 content,
451 })
452 .collect());
453 }
454
455 let mut by_package: BTreeMap<String, Vec<String>> = BTreeMap::new();
461 for f in &files {
462 by_package
463 .entry(f.package.clone())
464 .or_default()
465 .push(f.name.clone());
466 }
467 for (package, names) in by_package {
468 let mut content = String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n");
469 for n in &names {
470 content.push_str(&format!("include!({n:?});\n"));
472 }
473 files.push(GeneratedFile {
474 name: buffa_codegen::package_to_mod_filename(&package),
475 package,
476 kind: GeneratedFileKind::PackageMod,
477 content,
478 });
479 }
480
481 Ok(files)
482}
483
484pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
621 let mut options = Options::default();
622
623 if let Some(ref param) = request.parameter {
624 for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
625 if let Some(value) = opt.strip_prefix("buffa_module=") {
626 let rust = value.trim();
627 if rust.is_empty() {
628 anyhow::bail!(
629 "buffa_module requires a non-empty path, \
630 e.g. buffa_module=crate::proto"
631 );
632 }
633 options
634 .buffa
635 .extern_paths
636 .push((".".into(), rust.to_string()));
637 } else if let Some(value) = opt.strip_prefix("extern_path=") {
638 let (proto, rust) = value.split_once('=').ok_or_else(|| {
640 anyhow::anyhow!(
641 "invalid extern_path format {value:?}, expected \
642 extern_path=.proto.pkg=::rust::path"
643 )
644 })?;
645 let proto = proto.trim();
646 let rust = rust.trim();
647 if proto.is_empty() || rust.is_empty() {
648 anyhow::bail!(
649 "invalid extern_path format {value:?}, expected \
650 extern_path=.proto.pkg=::rust::path (both sides non-empty)"
651 );
652 }
653 let mut proto = proto.to_string();
654 if !proto.starts_with('.') {
655 proto.insert(0, '.');
656 }
657 options.buffa.extern_paths.push((proto, rust.to_string()));
658 } else if let Some(value) = opt.strip_prefix("gate_client_feature=") {
659 let feature = value.trim();
660 if feature.is_empty() {
661 anyhow::bail!("gate_client_feature requires a non-empty feature name");
662 }
663 options.gate_client_feature = true;
664 options.client_feature_name = feature.to_string();
665 } else if let Some(value) = opt.strip_prefix("encodable_impls=") {
666 match value.trim() {
667 "all_messages" => options.encodable_impls = EncodableImpls::AllMessages,
668 "outputs" => options.encodable_impls = EncodableImpls::Outputs,
669 other => anyhow::bail!(
670 "invalid encodable_impls value {other:?}, expected \
671 `all_messages` or `outputs`"
672 ),
673 }
674 } else if opt
675 .split_once('=')
676 .is_some_and(|(key, _)| key.trim() == buffa_codegen::ELEMENT_MEMORY_LIMIT_OPT)
677 {
678 } else {
683 match opt {
684 "file_per_package" => options.buffa.file_per_package = true,
685 "strict_utf8_mapping" => options.buffa.strict_utf8_mapping = true,
686 "no_json" => options.buffa.generate_json = false,
687 "no_register_fn" => options.buffa.emit_register_fn = false,
688 "gate_client_feature" => options.gate_client_feature = true,
689 _ => {
690 return Err(anyhow::anyhow!(
691 "unknown plugin option: {opt:?}. Supported: \
692 buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
693 encodable_impls=<all_messages|outputs>, \
694 {mem}=<bytes|unlimited>, \
695 file_per_package, strict_utf8_mapping, no_json, \
696 no_register_fn, gate_client_feature, \
697 gate_client_feature=<name>",
698 mem = buffa_codegen::ELEMENT_MEMORY_LIMIT_OPT
699 ));
700 }
701 }
702 }
703 }
704 }
705
706 let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
707
708 let files: Vec<CodeGeneratorResponseFile> = generated
709 .into_iter()
710 .map(|g| CodeGeneratorResponseFile {
711 name: Some(g.name),
712 content: Some(g.content),
713 ..Default::default()
714 })
715 .collect();
716
717 Ok(CodeGeneratorResponse {
718 supported_features: Some(feature_flags()),
719 minimum_edition: Some(Edition::EDITION_2023 as i32),
720 maximum_edition: Some(Edition::EDITION_2024 as i32),
721 file: files,
722 ..Default::default()
723 })
724}
725
726fn feature_flags() -> u64 {
729 const FEATURE_PROTO3_OPTIONAL: u64 = 1;
730 const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
731 FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
732}
733
734fn format_token_stream(tokens: &TokenStream) -> Result<String> {
736 let file = syn::parse2::<syn::File>(tokens.clone())
737 .map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
738 Ok(prettyplease::unparse(&file))
739}
740
741fn doc_attrs(text: &str) -> TokenStream {
750 let lines: Vec<String> = text
751 .lines()
752 .map(|l| {
753 if l.is_empty() {
754 String::new()
755 } else {
756 format!(" {l}")
757 }
758 })
759 .collect();
760 quote! { #(#[doc = #lines])* }
761}
762
763struct TypeResolver<'a> {
776 ctx: buffa_codegen::context::CodeGenContext<'a>,
777 require_extern: bool,
783}
784
785impl<'a> TypeResolver<'a> {
786 fn new(
787 proto_file: &'a [FileDescriptorProto],
788 file_to_generate: &[String],
789 config: &'a buffa_codegen::CodeGenConfig,
790 require_extern: bool,
791 ) -> Self {
792 Self {
793 ctx: buffa_codegen::context::CodeGenContext::for_generate(
794 proto_file,
795 file_to_generate,
796 config,
797 ),
798 require_extern,
799 }
800 }
801
802 fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
808 match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
809 Some(path) => {
810 self.check_extern_coverage(proto_fqn, &path)?;
811 Ok(path)
812 }
813 None => Err(self.unresolved_type_error(proto_fqn)),
814 }
815 }
816
817 fn check_extern_coverage(&self, proto_fqn: &str, path_prefix: &str) -> Result<()> {
821 if self.require_extern
822 && !path_prefix.starts_with("::")
823 && !path_prefix.starts_with("crate::")
824 {
825 anyhow::bail!(
826 "type {proto_fqn} is not covered by any extern_path mapping. \
827 Add extern_path=.=<your_buffa_module> (e.g. \
828 extern_path=.=crate::proto) to the plugin opts."
829 );
830 }
831 Ok(())
832 }
833
834 fn unresolved_type_error(&self, proto_fqn: &str) -> anyhow::Error {
841 let hint = if self.require_extern {
842 ""
843 } else {
844 " for a precompiled descriptor set, rebuild it with --include_imports"
845 };
846 anyhow::anyhow!(
847 "type {proto_fqn} not found in descriptor set (missing proto import?{hint})"
848 )
849 }
850
851 fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
853 let path = self.resolve_path(proto_fqn, current_package)?;
854 Ok(rust_path_to_tokens(&path))
855 }
856
857 fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
864 use buffa_codegen::context::SENTINEL_MOD;
865 let (to_package, within) =
866 match self
867 .ctx
868 .rust_type_relative_split(proto_fqn, current_package, 0)
869 {
870 Some(s) => {
871 self.check_extern_coverage(proto_fqn, &s.to_package)?;
872 (s.to_package, s.within_package)
873 }
874 None => return Err(self.unresolved_type_error(proto_fqn)),
875 };
876 let prefix = if to_package.is_empty() {
877 format!("{SENTINEL_MOD}::view")
878 } else {
879 format!("{to_package}::{SENTINEL_MOD}::view")
880 };
881 Ok(rust_path_to_tokens(&format!("{prefix}::{within}View")))
882 }
883}
884
885fn bare_type_name(proto_fqn: &str) -> &str {
887 proto_fqn
888 .strip_prefix('.')
889 .unwrap_or(proto_fqn)
890 .rsplit('.')
891 .next()
892 .unwrap_or(proto_fqn)
893}
894
895#[derive(Default)]
902struct BatchState {
903 encodable_seen: std::collections::BTreeSet<String>,
906 alias_seen: std::collections::BTreeSet<(String, String)>,
910 colliding_aliases: std::collections::BTreeSet<(String, String)>,
923 gate_client_feature: bool,
929 client_feature_name: String,
931 all_message_encodable_impls: bool,
935}
936
937impl BatchState {
938 fn client_feature_name(&self) -> &str {
939 if self.client_feature_name.is_empty() {
940 "client"
941 } else {
942 &self.client_feature_name
943 }
944 }
945}
946
947fn generate_connect_services(
948 file: &FileDescriptorProto,
949 resolver: &TypeResolver<'_>,
950 batch: &mut BatchState,
951) -> Result<TokenStream> {
952 let mut tokens = TokenStream::new();
953
954 tokens.extend(generate_owned_view_aliases(file, resolver, batch)?);
964 tokens.extend(generate_encodable_view_impls(file, resolver, batch)?);
965 if batch.all_message_encodable_impls {
966 tokens.extend(generate_all_message_encodable_impls(file, resolver, batch)?);
967 }
968
969 for service in &file.service {
970 tokens.extend(generate_service(file, service, resolver, batch)?);
971 }
972
973 Ok(tokens)
974}
975
976fn owned_view_alias_ident(fqn: &str) -> Ident {
979 format_ident!("Owned{}View", bare_type_name(fqn).to_upper_camel_case())
980}
981
982fn alias_collides(batch: &BatchState, current_package: &str, proto_fqn: &str) -> bool {
990 let alias = owned_view_alias_ident(proto_fqn).to_string();
991 batch
992 .colliding_aliases
993 .contains(&(current_package.to_string(), alias))
994}
995
996fn router_stream_items_tokens(
1003 resolver: &TypeResolver<'_>,
1004 method: &MethodDescriptorProto,
1005 package: &str,
1006) -> TokenStream {
1007 let input_fqn = method.input_type.as_deref().unwrap_or("");
1008 let input_owned = resolver
1012 .rust_type(input_fqn, package)
1013 .expect("rust_type failed for streaming input type");
1014 quote! {
1015 let req = ::connectrpc::dispatcher::codegen::into_stream_messages::<#input_owned>(req);
1016 }
1017}
1018
1019fn stream_items_doc(method: &MethodDescriptorProto) -> TokenStream {
1026 let mut doc = quote! {
1027 #[doc = ""]
1028 #[doc = " Each `requests` item is a [`StreamMessage`](::connectrpc::StreamMessage):"]
1029 #[doc = " it owns its buffer, is `Send + 'static`, and exposes zero-copy"]
1030 #[doc = " accessor methods (`item.name()`), `.view()`, and"]
1031 #[doc = " `.to_owned_message()`."]
1032 };
1033 if method.input_type == method.output_type {
1034 doc.extend(quote! {
1035 #[doc = " Items can be yielded back unchanged"]
1036 #[doc = " (`StreamMessage<M>` implements `Encodable<M>`)."]
1037 });
1038 }
1039 doc
1040}
1041
1042fn stream_owned_message_type(
1045 resolver: &TypeResolver<'_>,
1046 method: &MethodDescriptorProto,
1047 package: &str,
1048) -> Result<TokenStream> {
1049 let input_fqn = method.input_type.as_deref().unwrap_or("");
1050 let input_owned = resolver.rust_type(input_fqn, package)?;
1051 Ok(quote! { #input_owned })
1052}
1053
1054fn collect_alias_collisions(
1066 proto_file: &[FileDescriptorProto],
1067 file_to_generate: &[String],
1068) -> std::collections::BTreeSet<(String, String)> {
1069 use std::collections::BTreeMap;
1070 let mut first_seen: BTreeMap<(String, String), String> = BTreeMap::new();
1073 let mut colliding: std::collections::BTreeSet<(String, String)> =
1074 std::collections::BTreeSet::new();
1075
1076 for file_name in file_to_generate {
1077 let Some(file) = proto_file
1078 .iter()
1079 .find(|f| f.name.as_deref() == Some(file_name.as_str()))
1080 else {
1081 continue;
1082 };
1083 let package = file.package.clone().unwrap_or_default();
1084 for service in &file.service {
1085 for m in &service.method {
1086 for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
1087 .into_iter()
1088 .flatten()
1089 {
1090 let alias = owned_view_alias_ident(fqn).to_string();
1091 let key = (package.clone(), alias);
1092 match first_seen.get(&key) {
1093 Some(prev) if prev != fqn => {
1094 colliding.insert(key);
1095 }
1096 Some(_) => {} None => {
1098 first_seen.insert(key, fqn.to_string());
1099 }
1100 }
1101 }
1102 }
1103 }
1104 }
1105 colliding
1106}
1107
1108fn generate_owned_view_aliases(
1125 file: &FileDescriptorProto,
1126 resolver: &TypeResolver<'_>,
1127 batch: &mut BatchState,
1128) -> Result<TokenStream> {
1129 let package = file.package.as_deref().unwrap_or("");
1130 let mut out = TokenStream::new();
1131 for service in &file.service {
1132 for m in &service.method {
1133 for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
1134 .into_iter()
1135 .flatten()
1136 {
1137 if alias_collides(batch, package, fqn) {
1138 continue;
1139 }
1140 if !batch
1141 .alias_seen
1142 .insert((package.to_string(), fqn.to_string()))
1143 {
1144 continue;
1145 }
1146 let alias = owned_view_alias_ident(fqn);
1147 let view = resolver.rust_view_type(fqn, package)?;
1148 let doc = format!(
1149 "Shorthand for `OwnedView<{}View<'static>>`.",
1150 bare_type_name(fqn).to_upper_camel_case()
1151 );
1152 out.extend(quote! {
1153 #[doc = #doc]
1154 pub type #alias = ::buffa::view::OwnedView<#view<'static>>;
1155 });
1156 }
1157 }
1158 }
1159 Ok(out)
1160}
1161
1162fn generate_encodable_view_impls(
1178 file: &FileDescriptorProto,
1179 resolver: &TypeResolver<'_>,
1180 batch: &mut BatchState,
1181) -> Result<TokenStream> {
1182 let package = file.package.as_deref().unwrap_or("");
1183 let mut out = TokenStream::new();
1184 for service in &file.service {
1185 for m in &service.method {
1186 let fqn = m.output_type.as_deref().unwrap_or("");
1187 if let Some(impls) = encodable_impl_pair(fqn, package, resolver, batch)? {
1188 out.extend(impls);
1189 }
1190 }
1191 }
1192 Ok(out)
1193}
1194
1195fn encodable_impl_pair(
1200 fqn: &str,
1201 package: &str,
1202 resolver: &TypeResolver<'_>,
1203 batch: &mut BatchState,
1204) -> Result<Option<TokenStream>> {
1205 if !batch.encodable_seen.insert(fqn.to_string()) {
1206 return Ok(None);
1207 }
1208 let path = resolver.resolve_path(fqn, package)?;
1209 if path.starts_with("::") {
1212 return Ok(None);
1213 }
1214 let owned = resolver.rust_type(fqn, package)?;
1215 let view = resolver.rust_view_type(fqn, package)?;
1216 Ok(Some(quote! {
1217 impl ::connectrpc::Encodable<#owned> for #view<'_> {
1218 fn encode(&self, codec: ::connectrpc::CodecFormat)
1219 -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
1220 {
1221 ::connectrpc::__codegen::encode_view_body(self, codec)
1222 }
1223 }
1224 impl ::connectrpc::Encodable<#owned> for ::buffa::view::OwnedView<#view<'static>> {
1225 fn encode(&self, codec: ::connectrpc::CodecFormat)
1226 -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
1227 {
1228 ::connectrpc::__codegen::encode_view_body(self.reborrow(), codec)
1229 }
1230
1231 fn encode_segments(&self, codec: ::connectrpc::CodecFormat)
1236 -> ::std::result::Result<::connectrpc::EncodedBody, ::connectrpc::ConnectError>
1237 {
1238 ::connectrpc::__codegen::encode_view_body_segments(
1239 self.reborrow(),
1240 self.bytes(),
1241 codec,
1242 )
1243 }
1244 }
1245 }))
1246}
1247
1248fn generate_all_message_encodable_impls(
1258 file: &FileDescriptorProto,
1259 resolver: &TypeResolver<'_>,
1260 batch: &mut BatchState,
1261) -> Result<TokenStream> {
1262 fn recurse(
1263 msg: &DescriptorProto,
1264 fqn_prefix: &str,
1265 package: &str,
1266 resolver: &TypeResolver<'_>,
1267 batch: &mut BatchState,
1268 out: &mut TokenStream,
1269 ) -> Result<()> {
1270 if msg
1272 .options
1273 .as_option()
1274 .is_some_and(|o| o.map_entry.unwrap_or(false))
1275 {
1276 return Ok(());
1277 }
1278 let name = msg.name.as_deref().unwrap_or("");
1279 let fqn = format!("{fqn_prefix}.{name}");
1280 if let Some(impls) = encodable_impl_pair(&fqn, package, resolver, batch)? {
1281 out.extend(impls);
1282 }
1283 for nested in &msg.nested_type {
1284 recurse(nested, &fqn, package, resolver, batch, out)?;
1285 }
1286 Ok(())
1287 }
1288
1289 let package = file.package.as_deref().unwrap_or("");
1290 let fqn_prefix = if package.is_empty() {
1291 String::new()
1292 } else {
1293 format!(".{package}")
1294 };
1295 let mut out = TokenStream::new();
1296 for msg in &file.message_type {
1297 recurse(msg, &fqn_prefix, package, resolver, batch, &mut out)?;
1298 }
1299 Ok(out)
1300}
1301
1302fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
1313 let mut seen: HashMap<String, String> = HashMap::new();
1314 for m in &service.method {
1315 let proto_name = m.name.as_deref().unwrap_or("");
1316 let snake = proto_name.to_snake_case();
1317 let idents = [snake.clone(), format!("{snake}_with_options")];
1318 for ident in &idents {
1319 if let Some(prev) = seen.get(ident) {
1320 anyhow::bail!(
1321 "service {service_name}: RPC methods {prev:?} and {proto_name:?} \
1322 both generate Rust identifier `{ident}`; rename one in the proto"
1323 );
1324 }
1325 }
1326 for ident in idents {
1327 seen.insert(ident, proto_name.to_string());
1328 }
1329 }
1330 Ok(())
1331}
1332
1333fn generate_service(
1334 file: &FileDescriptorProto,
1335 service: &ServiceDescriptorProto,
1336 resolver: &TypeResolver<'_>,
1337 batch: &BatchState,
1338) -> Result<TokenStream> {
1339 let package = file.package.as_deref().unwrap_or("");
1340 let service_name = service.name.as_deref().unwrap_or("");
1341 check_method_collisions(service_name, service)?;
1342 let full_service_name = if package.is_empty() {
1345 service_name.to_string()
1346 } else {
1347 format!("{package}.{service_name}")
1348 };
1349 let service_upper = service_name.to_upper_camel_case();
1350 let trait_name = if service_upper == "Self" {
1354 format_ident!("Self_")
1355 } else {
1356 format_ident!("{}", service_upper)
1357 };
1358 let ext_trait_name = format_ident!("{}Ext", service_upper);
1359 let register_marker_name = format_ident!("{}RegisterMarker", service_upper);
1360 let client_name = format_ident!("{}Client", service_upper);
1361 let server_name = format_ident!("{}Server", service_upper);
1362 let service_name_const = format_ident!(
1363 "{}_SERVICE_NAME",
1364 service_name.to_snake_case().to_uppercase()
1365 );
1366
1367 let service_doc = get_service_comment(file, service).unwrap_or_default();
1369 let base_doc = if service_doc.is_empty() {
1370 format!("Server trait for {service_name}.")
1371 } else {
1372 service_doc
1373 };
1374 let full_doc = format!(
1375 "{base_doc}\n\n\
1376 # Implementing handlers\n\n\
1377 Implement methods with plain `async fn`; the returned future satisfies\n\
1378 the `Send` bound automatically.\n\n\
1379 **Unary and server-streaming requests** arrive as\n\
1380 [`ServiceRequest<'_, Req>`](::connectrpc::ServiceRequest): a zero-copy\n\
1381 view of the request plus its body, valid for the duration of the call.\n\
1382 Fields are read directly (`request.name` is a `&str` into the decoded\n\
1383 buffer) and the borrow may be held across `.await` points. Anything\n\
1384 that must outlive the call — `tokio::spawn`, channels, server state,\n\
1385 or data captured by a returned response stream — takes owned data:\n\
1386 call `request.to_owned_message()` (or copy the specific fields)\n\
1387 first.\n\n\
1388 **Client-streaming and bidi requests** arrive as\n\
1389 [`InboundStream<Req>`](::connectrpc::InboundStream) — a\n\
1390 `ServiceStream` of [`StreamMessage`](::connectrpc::StreamMessage)s.\n\
1391 Each item owns its decoded buffer and is `Send + 'static`, so items\n\
1392 can be buffered or moved into spawned tasks; read fields zero-copy\n\
1393 through the generated accessor methods (`item.name()`) or `.view()`,\n\
1394 convert with `.to_owned_message()`, or yield an item back unchanged —\n\
1395 `StreamMessage<M>` implements `Encodable<M>`.\n\n\
1396 Request types resolved through `extern_path` (e.g. well-known types\n\
1397 from another crate) use the same wrappers; the crate that owns the\n\
1398 type must be generated with buffa ≥ 0.9.0 and views enabled so the\n\
1399 backing `HasMessageView` impl exists.\n\n\
1400 The `impl Encodable<Out>` return bound accepts the owned `Out`, the\n\
1401 generated `OutView<'_>` / `OwnedOutView`,\n\
1402 [`MaybeBorrowed`](::connectrpc::MaybeBorrowed), or\n\
1403 [`PreEncoded`](::connectrpc::PreEncoded) for handlers that encode a\n\
1404 non-`'static` view internally and pass the bytes across the handler\n\
1405 boundary. View bodies are not emitted for output types mapped via\n\
1406 `extern_path` (the impl would be an orphan); return owned for\n\
1407 WKT/extern outputs.\n\n\
1408 Server-streaming and bidi-streaming methods return\n\
1409 `ServiceStream<impl Encodable<Out> + Send + use<Self>>`. The\n\
1410 `use<Self>` precise-capturing clause excludes `&self`'s lifetime and\n\
1411 the request's lifetime (unary methods use `use<'a, Self>` and may\n\
1412 borrow from `&self`), so stream items must be `'static` and cannot\n\
1413 borrow from the request. To stream view-encoded data, encode each\n\
1414 item inside the stream body and yield\n\
1415 [`PreEncoded`](::connectrpc::PreEncoded) — see its `# Streaming\n\
1416 example` doc."
1417 );
1418 let service_doc_tokens = doc_attrs(&full_doc);
1419
1420 let trait_methods: Vec<TokenStream> = service
1422 .method
1423 .iter()
1424 .map(|m| generate_trait_method(file, service, m, resolver, package))
1425 .collect::<Result<Vec<_>>>()?;
1426
1427 let route_registrations: Vec<TokenStream> = service
1429 .method
1430 .iter()
1431 .map(|m| {
1432 let method_name = m.name.as_deref().unwrap_or("");
1433 let method_snake = make_field_ident(&method_name.to_snake_case());
1434 let spec_const = method_spec_const_ident(service, method_name);
1438
1439 let client_streaming = m.client_streaming.unwrap_or(false);
1440 let server_streaming = m.server_streaming.unwrap_or(false);
1441
1442 let route_call = if server_streaming && !client_streaming {
1443 let output_type = resolver
1448 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1449 .unwrap();
1450 let input_fqn = m.input_type.as_deref().unwrap_or("");
1451 let input_view = resolver.rust_view_type(input_fqn, package).unwrap();
1452 let input_owned = resolver.rust_type(input_fqn, package).unwrap();
1453 let call_handler = quote! {
1454 let sreq = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(req.reborrow(), req.bytes());
1455 svc.#method_snake(ctx, sreq).await
1456 };
1457 quote! {
1458 .route_view_server_stream::<_, _, #output_type>(
1459 #service_name_const,
1460 #method_name,
1461 ::connectrpc::view_streaming_handler_fn({
1462 let svc = ::std::sync::Arc::clone(&self);
1463 move |ctx, req: ::buffa::view::OwnedView<#input_view<'static>>| {
1464 let svc = ::std::sync::Arc::clone(&svc);
1465 async move {
1466 #call_handler
1469 }
1470 }
1471 }),
1472 )
1473 }
1474 } else if client_streaming && !server_streaming {
1475 let output_type = resolver
1477 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1478 .unwrap();
1479 let into_items = router_stream_items_tokens(resolver, m, package);
1480 quote! {
1481 .route_view_client_stream(
1482 #service_name_const,
1483 #method_name,
1484 ::connectrpc::view_client_streaming_handler_fn({
1485 let svc = ::std::sync::Arc::clone(&self);
1486 move |ctx, req, format| {
1487 let svc = ::std::sync::Arc::clone(&svc);
1488 async move {
1489 #into_items
1490 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1491 }
1492 }
1493 }),
1494 )
1495 }
1496 } else if client_streaming && server_streaming {
1497 let output_type = resolver
1500 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1501 .unwrap();
1502 let into_items = router_stream_items_tokens(resolver, m, package);
1503 quote! {
1504 .route_view_bidi_stream::<_, _, #output_type>(
1505 #service_name_const,
1506 #method_name,
1507 ::connectrpc::view_bidi_streaming_handler_fn({
1508 let svc = ::std::sync::Arc::clone(&self);
1509 move |ctx, req| {
1510 let svc = ::std::sync::Arc::clone(&svc);
1511 async move {
1512 #into_items
1513 svc.#method_snake(ctx, req).await
1514 }
1515 }
1516 }),
1517 )
1518 }
1519 } else {
1520 let is_idempotent = m
1522 .options
1523 .idempotency_level
1524 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1525 .unwrap_or(false);
1526
1527 let route_method = if is_idempotent {
1528 quote! { route_view_idempotent }
1529 } else {
1530 quote! { route_view }
1531 };
1532 let output_type = resolver
1533 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1534 .unwrap();
1535 let input_fqn = m.input_type.as_deref().unwrap_or("");
1539 let input_view = resolver.rust_view_type(input_fqn, package).unwrap();
1540 let input_owned = resolver.rust_type(input_fqn, package).unwrap();
1541 let call_handler = quote! {
1542 let sreq = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(req.reborrow(), req.bytes());
1543 svc.#method_snake(ctx, sreq).await?.encode::<#output_type>(format)
1544 };
1545
1546 quote! {
1547 .#route_method(
1548 #service_name_const,
1549 #method_name,
1550 {
1551 let svc = ::std::sync::Arc::clone(&self);
1552 ::connectrpc::view_handler_fn(move |ctx, req: ::buffa::view::OwnedView<#input_view<'static>>, format| {
1553 let svc = ::std::sync::Arc::clone(&svc);
1554 async move {
1555 #call_handler
1558 }
1559 })
1560 },
1561 )
1562 }
1563 };
1564
1565 quote! {
1566 #route_call
1567 .with_spec(#spec_const)
1568 }
1569 })
1570 .collect();
1571
1572 let client_methods: Vec<TokenStream> = service
1574 .method
1575 .iter()
1576 .map(|m| generate_client_method(service, &full_service_name, m, resolver, package))
1577 .collect::<Result<Vec<_>>>()?;
1578
1579 let service_server = generate_service_server(
1581 &full_service_name,
1582 &trait_name,
1583 &server_name,
1584 service,
1585 resolver,
1586 package,
1587 )?;
1588
1589 let example_method = service
1591 .method
1592 .first()
1593 .and_then(|m| m.name.as_deref())
1594 .map(|n| make_field_ident(&n.to_snake_case()).to_string())
1595 .unwrap_or_else(|| "method".to_string());
1596
1597 let client_name_str = client_name.to_string();
1599 let client_doc = format!(
1600 r#"Client for this service.
1601
1602Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
1603`Http2Connection` — it has honest `poll_ready` and composes with
1604`tower::balance` for multi-connection load balancing. For **Connect
1605over HTTP/1.1** (or unknown protocol), use `HttpClient`.
1606
1607# Example (gRPC / HTTP/2)
1608
1609```rust,ignore
1610use connectrpc::client::{{Http2Connection, ClientConfig}};
1611use connectrpc::Protocol;
1612
1613let uri: http::Uri = "http://localhost:8080".parse()?;
1614let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
1615let config = ClientConfig::new(uri).with_protocol(Protocol::Grpc);
1616
1617let client = {client_name_str}::new(conn, config);
1618let response = client.{example_method}(request).await?;
1619```
1620
1621# Example (Connect / HTTP/1.1 or ALPN)
1622
1623```rust,ignore
1624use connectrpc::client::{{HttpClient, ClientConfig}};
1625
1626let http = HttpClient::plaintext(); // cleartext http:// only
1627let config = ClientConfig::new("http://localhost:8080".parse()?);
1628
1629let client = {client_name_str}::new(http, config);
1630let response = client.{example_method}(request).await?;
1631```
1632
1633# Working with the response
1634
1635Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
1636[`view()`](::connectrpc::client::UnaryResponse::view) borrows the response
1637message, so field access is zero-copy:
1638
1639```rust,ignore
1640let resp = client.{example_method}(request).await?;
1641let name: &str = resp.view().name; // borrow into the response buffer
1642```
1643
1644If you need the owned struct (e.g. to store or pass by value), use
1645[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
1646
1647```rust,ignore
1648let owned = client.{example_method}(request).await?.into_owned();
1649```
1650
1651[`into_view()`](::connectrpc::client::UnaryResponse::into_view) keeps the
1652zero-copy decoded body (an `OwnedView`) without copying; field access on it
1653goes through `.reborrow()`. Streaming responses yield one
1654[`StreamMessage`](::connectrpc::StreamMessage) per received message from
1655`.message().await` — read fields zero-copy through the generated accessor
1656methods (`msg.name()`) or `.view()`, or convert with `.to_owned_message()`."#
1657 );
1658 let client_doc_tokens = doc_attrs(&client_doc);
1659 let client_cfg_attr: TokenStream = if batch.gate_client_feature {
1667 let feature_name = syn::LitStr::new(batch.client_feature_name(), Span::call_site());
1668 quote! { #[cfg(feature = #feature_name)] }
1669 } else {
1670 TokenStream::new()
1671 };
1672
1673 let spec_consts = generate_spec_consts(&full_service_name, service);
1678
1679 Ok(quote! {
1680 pub const #service_name_const: &str = #full_service_name;
1686
1687 #(#spec_consts)*
1688
1689 #service_doc_tokens
1690 #[allow(clippy::type_complexity)]
1691 pub trait #trait_name: Send + Sync + 'static {
1692 #(#trait_methods)*
1693 }
1694
1695 pub trait #ext_trait_name: #trait_name {
1711 fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router;
1716 }
1717
1718 impl<S: #trait_name> #ext_trait_name for S {
1719 fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router {
1720 router
1721 #(#route_registrations)*
1722 }
1723 }
1724
1725 #[doc(hidden)]
1727 pub struct #register_marker_name;
1728
1729 impl<S: #trait_name> ::connectrpc::ServiceRegister<#register_marker_name>
1730 for ::std::sync::Arc<S>
1731 {
1732 fn register_service(self, router: ::connectrpc::Router) -> ::connectrpc::Router {
1733 <S as #ext_trait_name>::register(self, router)
1734 }
1735 }
1736
1737 #service_server
1738
1739 #client_doc_tokens
1740 #client_cfg_attr
1741 #[derive(Clone)]
1742 pub struct #client_name<T> {
1743 transport: T,
1744 config: ::connectrpc::client::ClientConfig,
1745 }
1746
1747 #client_cfg_attr
1748 impl<T> #client_name<T>
1749 where
1750 T: ::connectrpc::client::ClientTransport,
1751 <T::ResponseBody as ::connectrpc::http_body::Body>::Error: ::std::fmt::Display,
1752 {
1753 pub fn new(transport: T, config: ::connectrpc::client::ClientConfig) -> Self {
1755 Self { transport, config }
1756 }
1757
1758 pub fn config(&self) -> &::connectrpc::client::ClientConfig {
1760 &self.config
1761 }
1762
1763 pub fn config_mut(&mut self) -> &mut ::connectrpc::client::ClientConfig {
1765 &mut self.config
1766 }
1767
1768 #(#client_methods)*
1769 }
1770 })
1771}
1772
1773fn method_spec_const_ident(service: &ServiceDescriptorProto, method_name: &str) -> Ident {
1778 let service_name = service.name.as_deref().unwrap_or("");
1779 format_ident!(
1780 "{}_{}_SPEC",
1781 service_name.to_snake_case().to_uppercase(),
1782 method_name.to_snake_case().to_uppercase()
1783 )
1784}
1785
1786fn generate_spec_consts(
1794 full_service_name: &str,
1795 service: &ServiceDescriptorProto,
1796) -> Vec<TokenStream> {
1797 service
1798 .method
1799 .iter()
1800 .map(|m| {
1801 let method_name = m.name.as_deref().unwrap_or("");
1802 let spec_const = method_spec_const_ident(service, method_name);
1803 let procedure = format!("/{full_service_name}/{method_name}");
1804 let cs = m.client_streaming.unwrap_or(false);
1805 let ss = m.server_streaming.unwrap_or(false);
1806 let stream_type = match (cs, ss) {
1807 (true, true) => quote! { ::connectrpc::StreamType::BidiStream },
1808 (true, false) => quote! { ::connectrpc::StreamType::ClientStream },
1809 (false, true) => quote! { ::connectrpc::StreamType::ServerStream },
1810 (false, false) => quote! { ::connectrpc::StreamType::Unary },
1811 };
1812 let idempotency_level = match m.options.idempotency_level {
1813 Some(IdempotencyLevel::NO_SIDE_EFFECTS) => {
1814 quote! { ::connectrpc::IdempotencyLevel::NoSideEffects }
1815 }
1816 Some(IdempotencyLevel::IDEMPOTENT) => {
1817 quote! { ::connectrpc::IdempotencyLevel::Idempotent }
1818 }
1819 _ => quote! { ::connectrpc::IdempotencyLevel::Unknown },
1820 };
1821 let doc = doc_attrs(&format!(
1822 "Static [`Spec`](::connectrpc::Spec) for the `{method_name}` RPC, as seen \
1823 by the server; the generated client passes it with \
1824 [`origin`](::connectrpc::Spec::origin) `Client` (compare across sides with \
1825 [`Spec::same_method`](::connectrpc::Spec::same_method))."
1826 ));
1827 quote! {
1828 #doc
1829 pub const #spec_const: ::connectrpc::Spec =
1830 ::connectrpc::Spec::server(#procedure, #stream_type)
1831 .with_idempotency_level(#idempotency_level);
1832 }
1833 })
1834 .collect()
1835}
1836
1837fn generate_service_server(
1844 full_service_name: &str,
1845 trait_name: &proc_macro2::Ident,
1846 server_name: &proc_macro2::Ident,
1847 service: &ServiceDescriptorProto,
1848 resolver: &TypeResolver<'_>,
1849 package: &str,
1850) -> Result<TokenStream> {
1851 let path_prefix = format!("{full_service_name}/");
1853
1854 let lookup_arms: Vec<TokenStream> = service
1856 .method
1857 .iter()
1858 .map(|m| {
1859 let method_name = m.name.as_deref().unwrap_or("");
1860 let client_streaming = m.client_streaming.unwrap_or(false);
1861 let server_streaming = m.server_streaming.unwrap_or(false);
1862 let is_idempotent = m
1863 .options
1864 .idempotency_level
1865 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1866 .unwrap_or(false);
1867 let spec_const = method_spec_const_ident(service, method_name);
1868
1869 let desc = if client_streaming && server_streaming {
1870 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::bidi_streaming() }
1871 } else if client_streaming {
1872 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::client_streaming() }
1873 } else if server_streaming {
1874 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::server_streaming() }
1875 } else {
1876 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::unary(#is_idempotent) }
1877 };
1878 quote! { #method_name => Some(#desc.with_spec(#spec_const)), }
1879 })
1880 .collect();
1881
1882 let mut call_unary_arms: Vec<TokenStream> = Vec::new();
1887 let mut call_ss_arms: Vec<TokenStream> = Vec::new();
1888 let mut call_cs_arms: Vec<TokenStream> = Vec::new();
1889 let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
1890
1891 for m in &service.method {
1892 let method_name = m.name.as_deref().unwrap_or("");
1893 let method_snake = make_field_ident(&method_name.to_snake_case());
1894 let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
1895 let output_type = resolver.rust_type(m.output_type.as_deref().unwrap_or(""), package)?;
1896 let cs = m.client_streaming.unwrap_or(false);
1897 let ss = m.server_streaming.unwrap_or(false);
1898
1899 let stream_decode = {
1902 let input_fqn = m.input_type.as_deref().unwrap_or("");
1903 let input_owned = resolver.rust_type(input_fqn, package)?;
1904 quote! { ::connectrpc::dispatcher::codegen::decode_message_request_stream::<#input_owned>(requests, format, ctx.decode_options().clone()) }
1905 };
1906
1907 if cs && ss {
1908 call_bidi_arms.push(quote! {
1910 #method_name => {
1911 let svc = ::std::sync::Arc::clone(&self.inner);
1912 Box::pin(async move {
1913 let req_stream = #stream_decode;
1914 let resp = svc.#method_snake(ctx, req_stream).await?;
1915 Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1916 })
1917 }
1918 });
1919 } else if cs {
1920 call_cs_arms.push(quote! {
1922 #method_name => {
1923 let svc = ::std::sync::Arc::clone(&self.inner);
1924 Box::pin(async move {
1925 let req_stream = #stream_decode;
1926 svc.#method_snake(ctx, req_stream).await?.encode::<#output_type>(format)
1927 })
1928 }
1929 });
1930 } else if ss {
1931 let input_fqn = m.input_type.as_deref().unwrap_or("");
1933 let input_owned = resolver.rust_type(input_fqn, package)?;
1934 let call_handler = quote! {
1935 let req = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(&req, &body);
1936 let resp = svc.#method_snake(ctx, req).await?;
1937 };
1938 call_ss_arms.push(quote! {
1939 #method_name => {
1940 let svc = ::std::sync::Arc::clone(&self.inner);
1941 Box::pin(async move {
1942 let body = ::connectrpc::dispatcher::codegen::request_proto_bytes::<#input_owned>(request, format)?;
1945 let req: #input_view<'_> = ::connectrpc::dispatcher::codegen::decode_borrowed_request_view(&body, ctx.decode_options())?;
1946 #call_handler
1947 Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1948 })
1949 }
1950 });
1951 } else {
1952 let input_fqn = m.input_type.as_deref().unwrap_or("");
1954 let input_owned = resolver.rust_type(input_fqn, package)?;
1955 let call_handler = quote! {
1956 let req = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(&req, &body);
1957 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1958 };
1959 call_unary_arms.push(quote! {
1960 #method_name => {
1961 let svc = ::std::sync::Arc::clone(&self.inner);
1962 Box::pin(async move {
1963 let body = ::connectrpc::dispatcher::codegen::request_proto_bytes::<#input_owned>(request.encoded()?, format)?;
1970 let req: #input_view<'_> = ::connectrpc::dispatcher::codegen::decode_borrowed_request_view(&body, ctx.decode_options())?;
1971 #call_handler
1972 })
1973 }
1974 });
1975 }
1976 }
1977
1978 let server_doc = format!(
1979 "Monomorphic dispatcher for `{trait_name}`.\n\n\
1980 Unlike `.register(Router)` which type-erases each method into an \
1981 `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
1982 via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
1983 # Example\n\n\
1984 ```rust,ignore\n\
1985 use connectrpc::ConnectRpcService;\n\n\
1986 let server = {server_name}::new(MyImpl);\n\
1987 let service = ConnectRpcService::new(server);\n\
1988 // hand `service` to axum/hyper as a fallback_service\n\
1989 ```"
1990 );
1991 let server_doc_tokens = doc_attrs(&server_doc);
1992
1993 Ok(quote! {
1994 #server_doc_tokens
1995 pub struct #server_name<T> {
1996 inner: ::std::sync::Arc<T>,
1997 }
1998
1999 impl<T: #trait_name> #server_name<T> {
2000 pub fn new(service: T) -> Self {
2002 Self { inner: ::std::sync::Arc::new(service) }
2003 }
2004
2005 pub fn from_arc(inner: ::std::sync::Arc<T>) -> Self {
2007 Self { inner }
2008 }
2009 }
2010
2011 impl<T> Clone for #server_name<T> {
2012 fn clone(&self) -> Self {
2013 Self { inner: ::std::sync::Arc::clone(&self.inner) }
2014 }
2015 }
2016
2017 impl<T: #trait_name> ::connectrpc::Dispatcher for #server_name<T> {
2018 #[inline]
2019 fn lookup(&self, path: &str) -> Option<::connectrpc::dispatcher::codegen::MethodDescriptor> {
2020 let method = path.strip_prefix(#path_prefix)?;
2021 match method {
2022 #(#lookup_arms)*
2023 _ => None,
2024 }
2025 }
2026
2027 fn call_unary(
2028 &self,
2029 path: &str,
2030 ctx: ::connectrpc::RequestContext,
2031 request: ::connectrpc::Payload,
2032 format: ::connectrpc::CodecFormat,
2033 ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
2034 let Some(method) = path.strip_prefix(#path_prefix) else {
2035 return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
2036 };
2037 let _ = (&ctx, &request, &format);
2039 match method {
2040 #(#call_unary_arms)*
2041 _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
2042 }
2043 }
2044
2045 fn call_server_streaming(
2046 &self,
2047 path: &str,
2048 ctx: ::connectrpc::RequestContext,
2049 request: ::buffa::bytes::Bytes,
2050 format: ::connectrpc::CodecFormat,
2051 ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
2052 let Some(method) = path.strip_prefix(#path_prefix) else {
2053 return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
2054 };
2055 let _ = (&ctx, &request, &format);
2056 match method {
2057 #(#call_ss_arms)*
2058 _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
2059 }
2060 }
2061
2062 fn call_client_streaming(
2063 &self,
2064 path: &str,
2065 ctx: ::connectrpc::RequestContext,
2066 requests: ::connectrpc::dispatcher::codegen::RequestStream,
2067 format: ::connectrpc::CodecFormat,
2068 ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
2069 let Some(method) = path.strip_prefix(#path_prefix) else {
2070 return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
2071 };
2072 let _ = (&ctx, &requests, &format);
2073 match method {
2074 #(#call_cs_arms)*
2075 _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
2076 }
2077 }
2078
2079 fn call_bidi_streaming(
2080 &self,
2081 path: &str,
2082 ctx: ::connectrpc::RequestContext,
2083 requests: ::connectrpc::dispatcher::codegen::RequestStream,
2084 format: ::connectrpc::CodecFormat,
2085 ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
2086 let Some(method) = path.strip_prefix(#path_prefix) else {
2087 return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
2088 };
2089 let _ = (&ctx, &requests, &format);
2090 match method {
2091 #(#call_bidi_arms)*
2092 _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
2093 }
2094 }
2095 }
2096 })
2097}
2098
2099fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
2101 let comment = if doc.is_empty() { default } else { doc };
2102 doc_attrs(comment)
2103}
2104
2105fn generate_trait_method(
2107 file: &FileDescriptorProto,
2108 service: &ServiceDescriptorProto,
2109 method: &MethodDescriptorProto,
2110 resolver: &TypeResolver<'_>,
2111 package: &str,
2112) -> Result<TokenStream> {
2113 let method_name = method.name.as_deref().unwrap_or("");
2114 let method_snake = make_field_ident(&method_name.to_snake_case());
2115 let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
2116
2117 let method_doc = get_method_comment(file, service, method).unwrap_or_default();
2119 let method_doc_tokens =
2120 generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
2121
2122 let client_streaming = method.client_streaming.unwrap_or(false);
2124 let server_streaming = method.server_streaming.unwrap_or(false);
2125
2126 let borrow_doc = quote! {
2127 #[doc = ""]
2128 #[doc = " `'a` lets the response body borrow from `&self` (e.g. server-resident state)."]
2129 };
2130
2131 if server_streaming && !client_streaming {
2132 let input_fqn = method.input_type.as_deref().unwrap_or("");
2143 let input_owned = resolver.rust_type(input_fqn, package)?;
2144 let request_param = quote! { ::connectrpc::ServiceRequest<'_, #input_owned> };
2145 let request_doc = quote! {
2146 #[doc = ""]
2147 #[doc = " `request` is borrowed from the request body and is valid for the"]
2148 #[doc = " duration of the call (until the response stream is returned);"]
2149 #[doc = " message fields are read directly on it (zero-copy). Data the"]
2150 #[doc = " returned stream needs must be copied out or converted via"]
2151 #[doc = " `.to_owned_message()`."]
2152 };
2153 Ok(quote! {
2154 #method_doc_tokens
2155 #request_doc
2156 fn #method_snake(
2157 &self,
2158 ctx: ::connectrpc::RequestContext,
2159 request: #request_param,
2160 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
2161 })
2162 } else if client_streaming && !server_streaming {
2163 let stream_owned = stream_owned_message_type(resolver, method, package)?;
2169 let items_doc = stream_items_doc(method);
2170 Ok(quote! {
2171 #method_doc_tokens
2172 #borrow_doc
2173 #items_doc
2174 fn #method_snake<'a>(
2175 &'a self,
2176 ctx: ::connectrpc::RequestContext,
2177 requests: ::connectrpc::InboundStream<#stream_owned>,
2178 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
2179 })
2180 } else if client_streaming && server_streaming {
2181 let stream_owned = stream_owned_message_type(resolver, method, package)?;
2185 let items_doc = stream_items_doc(method);
2186 Ok(quote! {
2187 #method_doc_tokens
2188 #items_doc
2189 fn #method_snake(
2190 &self,
2191 ctx: ::connectrpc::RequestContext,
2192 requests: ::connectrpc::InboundStream<#stream_owned>,
2193 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
2194 })
2195 } else {
2196 let input_fqn = method.input_type.as_deref().unwrap_or("");
2207 let input_owned = resolver.rust_type(input_fqn, package)?;
2208 let request_param = quote! { ::connectrpc::ServiceRequest<'_, #input_owned> };
2209 let request_doc = quote! {
2210 #[doc = ""]
2211 #[doc = " `request` is borrowed from the request body and is valid for the"]
2212 #[doc = " duration of the call; message fields are read directly on it"]
2213 #[doc = " (zero-copy). The response cannot borrow from `request` — use"]
2214 #[doc = " `.to_owned_message()` (or copy the specific fields) for anything"]
2215 #[doc = " returned, stored, or moved into `tokio::spawn`."]
2216 };
2217 Ok(quote! {
2218 #method_doc_tokens
2219 #borrow_doc
2220 #request_doc
2221 fn #method_snake<'a>(
2222 &'a self,
2223 ctx: ::connectrpc::RequestContext,
2224 request: #request_param,
2225 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
2226 })
2227 }
2228}
2229
2230fn generate_client_method(
2241 service: &ServiceDescriptorProto,
2242 full_service_name: &str,
2243 method: &MethodDescriptorProto,
2244 resolver: &TypeResolver<'_>,
2245 package: &str,
2246) -> Result<TokenStream> {
2247 let method_name = method.name.as_deref().unwrap_or("");
2248 let spec_const = method_spec_const_ident(service, method_name);
2251 let client_spec = quote! { #spec_const.with_origin(::connectrpc::SpecOrigin::Client) };
2252 let method_snake = make_field_ident(&method_name.to_snake_case());
2253 let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
2254 let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
2255 let output_view_type =
2256 resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
2257
2258 let client_streaming = method.client_streaming.unwrap_or(false);
2259 let server_streaming = method.server_streaming.unwrap_or(false);
2260
2261 let doc = format!(
2262 " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
2263 );
2264 let doc_opts = format!(
2265 " Call the {method_name} RPC with explicit per-call options. \
2266 Options override [`ClientConfig`](::connectrpc::client::ClientConfig) defaults."
2267 );
2268
2269 let ret_ty: TokenStream;
2271 let call_body: TokenStream;
2272 let short_args: TokenStream; let opts_args: TokenStream; let short_delegate_args: TokenStream; let mut extra_doc = quote! {};
2277
2278 if client_streaming && !server_streaming {
2279 extra_doc = quote! {
2281 #[doc = ""]
2282 #[doc = " `requests` is any `Stream<Item = ...> + Send + 'static` of"]
2283 #[doc = " request messages (the `ClientRequestStream` bound); messages"]
2284 #[doc = " are sent as the stream yields them. It backs the request"]
2285 #[doc = " body, so yield owned messages or feed the call from a"]
2286 #[doc = " channel-backed stream. For a collection that is already in"]
2287 #[doc = " hand, wrap it with `::connectrpc::stream_iter(...)`."]
2288 #[doc = ""]
2289 #[doc = " Dropping the returned future cancels the call: the request"]
2290 #[doc = " body is dropped along with it, so messages the stream had"]
2291 #[doc = " not yet yielded are never delivered. A caller that needs the"]
2292 #[doc = " request delivered must drive the call to completion rather"]
2293 #[doc = " than, say, wrapping it in a `timeout`."]
2294 };
2295 ret_ty = quote! {
2296 Result<
2297 ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
2298 ::connectrpc::ConnectError,
2299 >
2300 };
2301 call_body = quote! {
2302 ::connectrpc::client::call_client_stream(
2303 &self.transport, &self.config,
2304 #client_spec,
2305 requests, options,
2306 ).await
2307 };
2308 short_args =
2309 quote! { requests: impl ::connectrpc::client::ClientRequestStream<#input_type> };
2310 opts_args = quote! { requests: impl ::connectrpc::client::ClientRequestStream<#input_type>, options: ::connectrpc::client::CallOptions };
2311 short_delegate_args = quote! { requests, ::connectrpc::client::CallOptions::default() };
2312 } else if client_streaming && server_streaming {
2313 ret_ty = quote! {
2315 Result<
2316 ::connectrpc::client::BidiStream<
2317 T::ResponseBody, #input_type, #output_view_type<'static>
2318 >,
2319 ::connectrpc::ConnectError,
2320 >
2321 };
2322 call_body = quote! {
2323 ::connectrpc::client::call_bidi_stream(
2324 &self.transport, &self.config,
2325 #client_spec, options,
2326 ).await
2327 };
2328 short_args = quote! {};
2329 opts_args = quote! { options: ::connectrpc::client::CallOptions };
2330 short_delegate_args = quote! { ::connectrpc::client::CallOptions::default() };
2331 } else if server_streaming {
2332 ret_ty = quote! {
2334 Result<
2335 ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
2336 ::connectrpc::ConnectError,
2337 >
2338 };
2339 call_body = quote! {
2340 ::connectrpc::client::call_server_stream(
2341 &self.transport, &self.config,
2342 #client_spec,
2343 request, options,
2344 ).await
2345 };
2346 short_args = quote! { request: #input_type };
2347 opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
2348 short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
2349 } else {
2350 ret_ty = quote! {
2352 Result<
2353 ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
2354 ::connectrpc::ConnectError,
2355 >
2356 };
2357 call_body = quote! {
2358 ::connectrpc::client::call_unary(
2359 &self.transport, &self.config,
2360 #client_spec,
2361 request, options,
2362 ).await
2363 };
2364 short_args = quote! { request: #input_type };
2365 opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
2366 short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
2367 }
2368
2369 Ok(quote! {
2370 #[doc = #doc]
2371 #extra_doc
2372 pub async fn #method_snake(&self, #short_args) -> #ret_ty {
2373 self.#method_with_opts(#short_delegate_args).await
2374 }
2375
2376 #[doc = #doc_opts]
2377 #extra_doc
2378 pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
2379 #call_body
2380 }
2381 })
2382}
2383
2384fn get_service_comment(
2386 file: &FileDescriptorProto,
2387 service: &ServiceDescriptorProto,
2388) -> Option<String> {
2389 let source_info: &SourceCodeInfo = &file.source_code_info;
2391
2392 let service_index = file.service.iter().position(|s| s.name == service.name)?;
2394
2395 let target_path = vec![6, service_index as i32];
2398
2399 find_comment(source_info, &target_path)
2400}
2401
2402fn get_method_comment(
2404 file: &FileDescriptorProto,
2405 service: &ServiceDescriptorProto,
2406 method: &MethodDescriptorProto,
2407) -> Option<String> {
2408 let source_info: &SourceCodeInfo = &file.source_code_info;
2409
2410 let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
2413 if s.name != service.name {
2414 return None;
2415 }
2416 s.method
2417 .iter()
2418 .position(|m| m.name == method.name)
2419 .map(|mi| (si, mi))
2420 })?;
2421
2422 let target_path = vec![6, service_index as i32, 2, method_index as i32];
2426
2427 find_comment(source_info, &target_path)
2428}
2429
2430fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
2432 for location in &source_info.location {
2433 if location.path == target_path {
2434 let comment = location
2435 .leading_comments
2436 .as_ref()
2437 .or(location.trailing_comments.as_ref())?;
2438
2439 let normalized: String = comment
2445 .lines()
2446 .map(|line| line.strip_prefix(' ').unwrap_or(line).trim_end())
2447 .collect::<Vec<_>>()
2448 .join("\n");
2449
2450 let cleaned = crate::comments::sanitize_comment(normalized.trim_matches('\n'));
2453
2454 if !cleaned.is_empty() {
2455 return Some(cleaned);
2456 }
2457 }
2458 }
2459 None
2460}
2461
2462#[cfg(test)]
2463mod tests {
2464 use super::*;
2465 use buffa_codegen::generated::descriptor::DescriptorProto;
2466 use quote::ToTokens;
2467
2468 #[test]
2469 fn doc_attrs_prefixes_space_for_prettyplease() {
2470 let ts = quote! {
2473 #[allow(dead_code)]
2474 mod m {}
2475 };
2476 let doc = doc_attrs("Hello.\n\nSecond paragraph.");
2477 let combined = quote! { #doc #ts };
2478 let file = syn::parse2::<syn::File>(combined).unwrap();
2479 let out = prettyplease::unparse(&file);
2480 assert!(out.contains("/// Hello."), "got: {out}");
2482 assert!(out.contains("/// Second paragraph."), "got: {out}");
2483 assert!(out.contains("///\n"), "got: {out}");
2485 assert!(!out.contains("///Hello"), "got: {out}");
2487 assert!(!out.contains("/// Hello"), "got: {out}");
2488 }
2489
2490 fn minimal_file(
2495 package: Option<&str>,
2496 input_type: &str,
2497 output_type: &str,
2498 local_messages: &[&str],
2499 ) -> FileDescriptorProto {
2500 minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
2501 }
2502
2503 fn minimal_file_with_method(
2506 package: Option<&str>,
2507 method_name: &str,
2508 input_type: &str,
2509 output_type: &str,
2510 local_messages: &[&str],
2511 ) -> FileDescriptorProto {
2512 let method = MethodDescriptorProto {
2513 name: Some(method_name.into()),
2514 input_type: Some(input_type.into()),
2515 output_type: Some(output_type.into()),
2516 ..Default::default()
2517 };
2518 let service = ServiceDescriptorProto {
2519 name: Some("PingService".into()),
2520 method: vec![method],
2521 ..Default::default()
2522 };
2523 FileDescriptorProto {
2524 name: Some("ping.proto".into()),
2525 package: package.map(|p| p.into()),
2526 service: vec![service],
2527 message_type: local_messages
2528 .iter()
2529 .map(|name| DescriptorProto {
2530 name: Some((*name).into()),
2531 ..Default::default()
2532 })
2533 .collect(),
2534 ..Default::default()
2535 }
2536 }
2537
2538 fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
2542 let methods = method_names
2543 .iter()
2544 .map(|n| MethodDescriptorProto {
2545 name: Some((*n).into()),
2546 input_type: Some(format!(".{package}.Empty")),
2547 output_type: Some(format!(".{package}.Empty")),
2548 ..Default::default()
2549 })
2550 .collect();
2551 let service = ServiceDescriptorProto {
2552 name: Some("PingService".into()),
2553 method: methods,
2554 ..Default::default()
2555 };
2556 FileDescriptorProto {
2557 name: Some("ping.proto".into()),
2558 package: Some(package.into()),
2559 service: vec![service],
2560 message_type: vec![DescriptorProto {
2561 name: Some("Empty".into()),
2562 ..Default::default()
2563 }],
2564 ..Default::default()
2565 }
2566 }
2567
2568 fn gen_service(
2577 files: &[FileDescriptorProto],
2578 target_idx: usize,
2579 extern_paths: &[(String, String)],
2580 require_extern: bool,
2581 ) -> Result<String> {
2582 let mut config = buffa_codegen::CodeGenConfig::default();
2583 config.extern_paths = extern_paths.to_vec();
2584 let target_name = files[target_idx]
2585 .name
2586 .clone()
2587 .into_iter()
2588 .collect::<Vec<_>>();
2589 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2590 let file = &files[target_idx];
2591 let service = &file.service[0];
2592 let batch = BatchState {
2593 colliding_aliases: collect_alias_collisions(files, &target_name),
2594 ..BatchState::default()
2595 };
2596 Ok(generate_service(file, service, &resolver, &batch)?.to_string())
2597 }
2598
2599 fn assert_no_top_level_use(formatted: &str, label: &str) {
2604 let parsed: syn::File = syn::parse_str(formatted).expect("formatted code parses");
2605 let offenders: Vec<String> = parsed
2606 .items
2607 .iter()
2608 .filter_map(|item| match item {
2609 syn::Item::Use(u) => Some(quote!(#u).to_string()),
2610 _ => None,
2611 })
2612 .collect();
2613 assert!(
2614 offenders.is_empty(),
2615 "{label} contains top-level use statement(s): {offenders:?}\nFull source:\n{formatted}"
2616 );
2617 }
2618
2619 fn gen_file(
2620 files: &[FileDescriptorProto],
2621 target_idx: usize,
2622 extern_paths: &[(String, String)],
2623 require_extern: bool,
2624 ) -> Result<String> {
2625 let mut config = buffa_codegen::CodeGenConfig::default();
2626 config.extern_paths = extern_paths.to_vec();
2627 let target_name = files[target_idx]
2628 .name
2629 .clone()
2630 .into_iter()
2631 .collect::<Vec<_>>();
2632 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2633 let mut batch = BatchState {
2634 colliding_aliases: collect_alias_collisions(files, &target_name),
2635 ..BatchState::default()
2636 };
2637 Ok(generate_connect_services(&files[target_idx], &resolver, &mut batch)?.to_string())
2638 }
2639
2640 #[test]
2641 fn unary_response_body_captures_self_lifetime() {
2642 let file = minimal_file(
2643 Some("example.v1"),
2644 ".example.v1.PingReq",
2645 ".example.v1.PingResp",
2646 &["PingReq", "PingResp"],
2647 );
2648 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2649 assert!(code.contains("< 'a >"), "trait method missing 'a: {code}");
2650 assert!(code.contains("& 'a self"), "missing &'a self: {code}");
2651 assert!(
2652 code.contains("use < 'a , Self >"),
2653 "missing use<'a, Self> capture: {code}"
2654 );
2655 assert!(
2656 !code.contains("'static + use"),
2657 "'static bound on body should be dropped: {code}"
2658 );
2659 }
2660
2661 #[test]
2662 fn owned_view_aliases_emitted_for_input_and_output() {
2663 let file = minimal_file(
2664 Some("example.v1"),
2665 ".example.v1.PingReq",
2666 ".example.v1.PingResp",
2667 &["PingReq", "PingResp"],
2668 );
2669 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2670 assert!(
2671 code.contains("pub type OwnedPingReqView = :: buffa :: view :: OwnedView"),
2672 "missing OwnedPingReqView alias: {code}"
2673 );
2674 assert!(
2675 code.contains("pub type OwnedPingRespView = :: buffa :: view :: OwnedView"),
2676 "missing OwnedPingRespView alias: {code}"
2677 );
2678 assert!(
2683 code.contains("request : :: connectrpc :: ServiceRequest < '_"),
2684 "unary trait method should take request: ServiceRequest<'_, PingReq>: {code}"
2685 );
2686 assert!(
2690 !code.contains("impl :: connectrpc :: HasMessageView for"),
2691 "connect-codegen must not emit view-family impls (buffa does): {code}"
2692 );
2693 }
2694
2695 #[test]
2696 fn cross_package_input_collision_suppresses_alias_for_both_sides() {
2697 let v1 = FileDescriptorProto {
2705 name: Some("api/v1/foo/bar/foobar.proto".into()),
2706 package: Some("api.v1.foo.bar".into()),
2707 message_type: vec![DescriptorProto {
2708 name: Some("MyMessage".into()),
2709 ..Default::default()
2710 }],
2711 ..Default::default()
2712 };
2713 let v2 = minimal_file(
2714 Some("api.v2.foo.bar"),
2715 ".api.v1.foo.bar.MyMessage",
2716 ".api.v2.foo.bar.MyMessage",
2717 &["MyMessage"],
2718 );
2719 let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2720
2721 let alias_count = code.matches("pub type OwnedMyMessageView").count();
2724 assert_eq!(
2725 alias_count, 0,
2726 "expected zero OwnedMyMessageView aliases when both sides collide; got {alias_count}: {code}"
2727 );
2728
2729 assert!(
2732 !code.contains("request : OwnedMyMessageView"),
2733 "colliding input must not reference the suppressed alias: {code}"
2734 );
2735 assert!(
2738 code.contains("request : :: connectrpc :: ServiceRequest < '_"),
2739 "colliding unary input should still use ServiceRequest: {code}"
2740 );
2741 }
2742
2743 #[test]
2744 fn cross_package_input_without_collision_keeps_alias() {
2745 let wkt = FileDescriptorProto {
2752 name: Some("google/protobuf/empty.proto".into()),
2753 package: Some("google.protobuf".into()),
2754 message_type: vec![DescriptorProto {
2755 name: Some("Empty".into()),
2756 ..Default::default()
2757 }],
2758 ..Default::default()
2759 };
2760 let svc = minimal_file(
2761 Some("example.v1"),
2762 ".google.protobuf.Empty",
2763 ".example.v1.PingResp",
2764 &["PingResp"],
2765 );
2766 let code = gen_file(&[wkt, svc], 1, &[], false).unwrap();
2767 assert!(
2768 code.contains("pub type OwnedEmptyView = :: buffa :: view :: OwnedView"),
2769 "WKT cross-package input should keep its alias: {code}"
2770 );
2771 assert!(
2777 code.contains(
2778 "request : :: connectrpc :: ServiceRequest < '_ , :: buffa_types :: google :: protobuf :: Empty >"
2779 ),
2780 "extern unary input should use ServiceRequest over the extern owned type: {code}"
2781 );
2782 }
2783
2784 #[test]
2785 fn collision_inlines_in_all_streaming_method_shapes() {
2786 let v1 = FileDescriptorProto {
2792 name: Some("api/v1/foo/bar/foobar.proto".into()),
2793 package: Some("api.v1.foo.bar".into()),
2794 message_type: vec![DescriptorProto {
2795 name: Some("MyMessage".into()),
2796 ..Default::default()
2797 }],
2798 ..Default::default()
2799 };
2800 let v2 = FileDescriptorProto {
2801 name: Some("api/v2/foo/bar/foobar.proto".into()),
2802 package: Some("api.v2.foo.bar".into()),
2803 message_type: vec![DescriptorProto {
2804 name: Some("MyMessage".into()),
2805 ..Default::default()
2806 }],
2807 service: vec![ServiceDescriptorProto {
2808 name: Some("FooBar".into()),
2809 method: vec![
2810 MethodDescriptorProto {
2811 name: Some("Unary".into()),
2812 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2813 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2814 ..Default::default()
2815 },
2816 MethodDescriptorProto {
2817 name: Some("ServerStream".into()),
2818 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2819 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2820 server_streaming: Some(true),
2821 ..Default::default()
2822 },
2823 MethodDescriptorProto {
2824 name: Some("ClientStream".into()),
2825 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2826 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2827 client_streaming: Some(true),
2828 ..Default::default()
2829 },
2830 MethodDescriptorProto {
2831 name: Some("Bidi".into()),
2832 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2833 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2834 client_streaming: Some(true),
2835 server_streaming: Some(true),
2836 ..Default::default()
2837 },
2838 ],
2839 ..Default::default()
2840 }],
2841 ..Default::default()
2842 };
2843 let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2844
2845 assert!(
2847 !code.contains("OwnedMyMessageView"),
2848 "no method shape should reference the suppressed alias: {code}"
2849 );
2850
2851 assert!(
2854 code.matches("request : :: connectrpc :: ServiceRequest < '_")
2855 .count()
2856 >= 2,
2857 "unary and server-streaming should take the borrowed ServiceRequest form: {code}"
2858 );
2859 assert!(
2862 code.matches("requests : :: connectrpc :: InboundStream <")
2863 .count()
2864 >= 2,
2865 "client-streaming and bidi should both take InboundStream items: {code}"
2866 );
2867 }
2868
2869 #[test]
2870 fn streaming_methods_use_encodable_item_type() {
2871 let file = FileDescriptorProto {
2879 name: Some("ex/v1/svc.proto".into()),
2880 package: Some("ex.v1".into()),
2881 message_type: vec![
2882 DescriptorProto {
2883 name: Some("Req".into()),
2884 ..Default::default()
2885 },
2886 DescriptorProto {
2887 name: Some("Resp".into()),
2888 ..Default::default()
2889 },
2890 ],
2891 service: vec![ServiceDescriptorProto {
2892 name: Some("Svc".into()),
2893 method: vec![
2894 MethodDescriptorProto {
2895 name: Some("ServerStream".into()),
2896 input_type: Some(".ex.v1.Req".into()),
2897 output_type: Some(".ex.v1.Resp".into()),
2898 server_streaming: Some(true),
2899 ..Default::default()
2900 },
2901 MethodDescriptorProto {
2902 name: Some("Bidi".into()),
2903 input_type: Some(".ex.v1.Req".into()),
2904 output_type: Some(".ex.v1.Resp".into()),
2905 client_streaming: Some(true),
2906 server_streaming: Some(true),
2907 ..Default::default()
2908 },
2909 ],
2910 ..Default::default()
2911 }],
2912 ..Default::default()
2913 };
2914 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2915
2916 assert_eq!(
2918 code.matches(":: connectrpc :: ServiceStream < impl :: connectrpc :: Encodable < Resp > + Send + use < Self >>")
2919 .count(),
2920 2,
2921 "server-streaming and bidi should both use the Encodable item type: {code}"
2922 );
2923
2924 assert_eq!(
2926 code.matches("encode_response_stream :: < Resp , _ , _ >")
2927 .count(),
2928 2,
2929 "dispatcher arms must turbofish Res to encode_response_stream: {code}"
2930 );
2931
2932 assert!(
2934 code.contains("route_view_server_stream :: < _ , _ , Resp >"),
2935 "route_view_server_stream must turbofish Res: {code}"
2936 );
2937 assert!(
2938 code.contains("route_view_bidi_stream :: < _ , _ , Resp >"),
2939 "route_view_bidi_stream must turbofish Res: {code}"
2940 );
2941 }
2942
2943 #[test]
2944 fn encodable_view_impls_emitted_per_output_type() {
2945 let file = minimal_file(
2946 Some("example.v1"),
2947 ".example.v1.PingReq",
2948 ".example.v1.PingResp",
2949 &["PingReq", "PingResp"],
2950 );
2951 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2952 assert!(
2953 code.contains(
2954 ":: connectrpc :: Encodable < PingResp > for __buffa :: view :: PingRespView"
2955 ),
2956 "missing Encodable<PingResp> for PingRespView: {code}"
2957 );
2958 assert!(
2959 code.contains(
2960 ":: connectrpc :: Encodable < PingResp > for :: buffa :: view :: OwnedView"
2961 ),
2962 "missing Encodable<PingResp> for OwnedView<PingRespView>: {code}"
2963 );
2964 assert!(!code.contains("Encodable < PingReq >"), "got: {code}");
2966 }
2967
2968 #[test]
2969 fn encodable_view_impls_skipped_for_extern_output() {
2970 let wkt = FileDescriptorProto {
2973 name: Some("google/protobuf/empty.proto".into()),
2974 package: Some("google.protobuf".into()),
2975 message_type: vec![DescriptorProto {
2976 name: Some("Empty".into()),
2977 ..Default::default()
2978 }],
2979 ..Default::default()
2980 };
2981 let file = minimal_file(
2982 Some("example.v1"),
2983 ".example.v1.PingReq",
2984 ".google.protobuf.Empty",
2985 &["PingReq"],
2986 );
2987 let code = gen_file(&[wkt, file], 1, &[], false).unwrap();
2988 assert!(
2991 !code.contains("encode_view_body"),
2992 "extern output type must not get Encodable impl: {code}"
2993 );
2994 }
2995
2996 #[test]
2997 fn encodable_view_impls_deduped_across_files() {
2998 let common = FileDescriptorProto {
3003 name: Some("common.proto".into()),
3004 package: Some("common.v1".into()),
3005 message_type: vec![DescriptorProto {
3006 name: Some("Reply".into()),
3007 ..Default::default()
3008 }],
3009 ..Default::default()
3010 };
3011 let svc = |name: &str, pkg: &str| FileDescriptorProto {
3012 name: Some(name.into()),
3013 package: Some(pkg.into()),
3014 message_type: vec![DescriptorProto {
3015 name: Some("Req".into()),
3016 ..Default::default()
3017 }],
3018 service: vec![ServiceDescriptorProto {
3019 name: Some("S".into()),
3020 method: vec![MethodDescriptorProto {
3021 name: Some("Call".into()),
3022 input_type: Some(format!(".{pkg}.Req")),
3023 output_type: Some(".common.v1.Reply".into()),
3024 ..Default::default()
3025 }],
3026 ..Default::default()
3027 }],
3028 ..Default::default()
3029 };
3030 let files = vec![common, svc("a.proto", "a.v1"), svc("b.proto", "b.v1")];
3031
3032 let generated = generate_files(
3033 &files,
3034 &["a.proto".into(), "b.proto".into()],
3035 &Options::default(),
3036 )
3037 .unwrap();
3038
3039 let companions: Vec<_> = generated
3042 .iter()
3043 .filter(|f| f.kind == GeneratedFileKind::Companion)
3044 .collect();
3045 let mut companion_names: Vec<&str> = companions.iter().map(|f| f.name.as_str()).collect();
3046 companion_names.sort_unstable();
3047 assert_eq!(companion_names, ["a.__connect.rs", "b.__connect.rs"]);
3048 for c in &companions {
3049 let stitcher = generated
3050 .iter()
3051 .find(|g| g.kind == GeneratedFileKind::PackageMod && g.package == c.package)
3052 .expect("each companion's package must have a stitcher");
3053 assert!(
3054 stitcher
3055 .content
3056 .contains(&format!("include!(\"{}\")", c.name)),
3057 "stitcher for {} must include companion {}",
3058 c.package,
3059 c.name
3060 );
3061 }
3062
3063 let combined: String = companions.iter().map(|f| f.content.as_str()).collect();
3064
3065 let view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor super::super::common::v1::__buffa::view::ReplyView<'_>";
3066 let owned_view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor ::buffa::view::OwnedView<";
3067 assert_eq!(
3068 combined.matches(view_impl).count(),
3069 1,
3070 "Encodable<Reply> for ReplyView<'_> must appear once: {combined}"
3071 );
3072 assert_eq!(
3073 combined.matches(owned_view_impl).count(),
3074 1,
3075 "Encodable<Reply> for OwnedView<ReplyView> must appear once: {combined}"
3076 );
3077 }
3078
3079 fn file_per_package_fixture() -> Vec<FileDescriptorProto> {
3084 let common = FileDescriptorProto {
3085 name: Some("common.proto".into()),
3086 package: Some("common.v1".into()),
3087 message_type: vec![DescriptorProto {
3088 name: Some("Reply".into()),
3089 ..Default::default()
3090 }],
3091 ..Default::default()
3092 };
3093 let svc = |proto_name: &str, pkg: &str, svc_name: &str, req: &str| FileDescriptorProto {
3098 name: Some(proto_name.into()),
3099 package: Some(pkg.into()),
3100 message_type: vec![DescriptorProto {
3101 name: Some(req.into()),
3102 ..Default::default()
3103 }],
3104 service: vec![ServiceDescriptorProto {
3105 name: Some(svc_name.into()),
3106 method: vec![MethodDescriptorProto {
3107 name: Some("Call".into()),
3108 input_type: Some(format!(".{pkg}.{req}")),
3109 output_type: Some(".common.v1.Reply".into()),
3110 ..Default::default()
3111 }],
3112 ..Default::default()
3113 }],
3114 ..Default::default()
3115 };
3116 vec![
3117 common,
3118 svc("a/x.proto", "a.v1", "XService", "XReq"),
3119 svc("a/y.proto", "a.v1", "YService", "YReq"),
3120 svc("b/z.proto", "b.v1", "ZService", "ZReq"),
3121 ]
3122 }
3123
3124 #[test]
3125 fn generate_files_file_per_package_inlines_companions() {
3126 let files = file_per_package_fixture();
3127 let mut options = Options::default();
3128 options.buffa.file_per_package = true;
3129
3130 let generated = generate_files(
3131 &files,
3132 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3133 &options,
3134 )
3135 .unwrap();
3136
3137 assert!(
3139 !generated
3140 .iter()
3141 .any(|f| f.kind == GeneratedFileKind::Companion),
3142 "file_per_package must not emit sibling Companion files"
3143 );
3144 assert!(
3145 !generated.iter().any(|f| f.name.ends_with(".__connect.rs")),
3146 "file_per_package must not emit `<stem>.__connect.rs` files"
3147 );
3148
3149 let a = generated
3151 .iter()
3152 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "a.v1")
3153 .expect("a.v1 PackageMod must exist");
3154 assert!(
3155 a.content.contains("pub trait XService"),
3156 "a.v1 missing XService"
3157 );
3158 assert!(
3159 a.content.contains("pub trait YService"),
3160 "a.v1 missing YService"
3161 );
3162 assert!(
3163 !a.content.contains("pub trait ZService"),
3164 "a.v1 must not inline ZService"
3165 );
3166 assert!(
3167 !a.content.contains("__connect.rs"),
3168 "a.v1 PackageMod must not include! a connect file: {}",
3169 a.content
3170 );
3171
3172 let b = generated
3173 .iter()
3174 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "b.v1")
3175 .expect("b.v1 PackageMod must exist");
3176 assert!(
3177 b.content.contains("pub trait ZService"),
3178 "b.v1 missing ZService"
3179 );
3180 assert!(
3181 !b.content.contains("pub trait XService"),
3182 "b.v1 must not inline XService"
3183 );
3184
3185 let pkg_mods = generated
3188 .iter()
3189 .filter(|f| f.kind == GeneratedFileKind::PackageMod)
3190 .count();
3191 assert_eq!(
3192 pkg_mods, 2,
3193 "expected exactly two PackageMods: {generated:#?}"
3194 );
3195
3196 let combined: String = generated.iter().map(|f| f.content.as_str()).collect();
3201 assert_eq!(
3202 combined
3203 .matches("impl ::connectrpc::Encodable<super::super::common::v1::Reply>")
3204 .count(),
3205 2,
3206 "Encodable<Reply> impls must be deduplicated across packages \
3207 (1 for ReplyView, 1 for OwnedView<ReplyView>): {combined}"
3208 );
3209 }
3210
3211 #[test]
3212 fn generate_services_file_per_package_emits_one_file_per_package() {
3213 let files = file_per_package_fixture();
3214 let mut options = Options::default();
3215 options.buffa.file_per_package = true;
3216 options
3217 .buffa
3218 .extern_paths
3219 .push((".".into(), "crate::proto".into()));
3220
3221 let generated = generate_services(
3222 &files,
3223 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3224 &options,
3225 )
3226 .unwrap();
3227
3228 assert_eq!(
3231 generated.len(),
3232 2,
3233 "expected exactly two output files: {generated:#?}"
3234 );
3235 assert!(
3236 generated
3237 .iter()
3238 .all(|f| f.kind == GeneratedFileKind::PackageMod),
3239 "all output files must be PackageMod"
3240 );
3241 assert!(
3242 !generated.iter().any(|f| f.name.ends_with(".mod.rs")),
3243 "file_per_package must not emit a separate stitcher"
3244 );
3245 assert!(
3246 !generated.iter().any(|f| f.content.contains("include!")),
3247 "file_per_package output must not include! sibling files"
3248 );
3249
3250 let mut names: Vec<&str> = generated.iter().map(|f| f.name.as_str()).collect();
3251 names.sort_unstable();
3252 assert_eq!(
3253 names,
3254 ["a.v1.rs", "b.v1.rs"],
3255 "filenames must be `<dotted.pkg>.rs` to match buffa's file_per_package convention"
3256 );
3257
3258 let a = generated.iter().find(|f| f.package == "a.v1").unwrap();
3259 assert!(a.content.contains("pub trait XService"));
3260 assert!(a.content.contains("pub trait YService"));
3261 let b = generated.iter().find(|f| f.package == "b.v1").unwrap();
3262 assert!(b.content.contains("pub trait ZService"));
3263 assert!(!b.content.contains("pub trait XService"));
3264 }
3265
3266 #[test]
3267 fn generate_services_file_per_package_default_layout_unchanged() {
3268 let files = file_per_package_fixture();
3271 let mut options = Options::default();
3272 options
3273 .buffa
3274 .extern_paths
3275 .push((".".into(), "crate::proto".into()));
3276
3277 let generated = generate_services(
3278 &files,
3279 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3280 &options,
3281 )
3282 .unwrap();
3283
3284 let mut companions: Vec<&str> = generated
3285 .iter()
3286 .filter(|f| f.kind == GeneratedFileKind::Companion)
3287 .map(|f| f.name.as_str())
3288 .collect();
3289 companions.sort_unstable();
3290 assert_eq!(
3291 companions,
3292 ["a.x.__connect.rs", "a.y.__connect.rs", "b.z.__connect.rs"],
3293 "default layout emits one companion per proto"
3294 );
3295 let mut stitchers: Vec<&str> = generated
3296 .iter()
3297 .filter(|f| f.kind == GeneratedFileKind::PackageMod)
3298 .map(|f| f.name.as_str())
3299 .collect();
3300 stitchers.sort_unstable();
3301 assert_eq!(
3302 stitchers,
3303 ["a.v1.mod.rs", "b.v1.mod.rs"],
3304 "default layout emits one stitcher per package"
3305 );
3306 let a_stitcher = generated.iter().find(|f| f.name == "a.v1.mod.rs").unwrap();
3308 assert!(
3309 a_stitcher
3310 .content
3311 .contains(r#"include!("a.x.__connect.rs");"#)
3312 );
3313 assert!(
3314 a_stitcher
3315 .content
3316 .contains(r#"include!("a.y.__connect.rs");"#)
3317 );
3318 }
3319
3320 #[test]
3321 fn service_name_with_package() {
3322 let file = minimal_file(
3323 Some("example.v1"),
3324 ".example.v1.PingReq",
3325 ".example.v1.PingResp",
3326 &["PingReq", "PingResp"],
3327 );
3328 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3329 assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
3330 }
3331
3332 #[test]
3333 fn service_name_without_package() {
3334 let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
3336 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3337 assert!(code.contains("\"PingService\""), "got: {code}");
3338 assert!(
3339 !code.contains("\".PingService\""),
3340 "must not have leading dot: {code}"
3341 );
3342 }
3343
3344 #[test]
3345 fn same_package_types_use_bare_names() {
3346 let file = minimal_file(
3347 Some("example.v1"),
3348 ".example.v1.PingReq",
3349 ".example.v1.PingResp",
3350 &["PingReq", "PingResp"],
3351 );
3352 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3353 assert!(code.contains("PingReq"), "input type missing: {code}");
3355 assert!(code.contains("PingResp"), "output type missing: {code}");
3356 assert!(
3358 !code.contains("super :: PingReq"),
3359 "unexpected super: {code}"
3360 );
3361 }
3362
3363 #[test]
3364 fn cross_package_types_use_relative_paths() {
3365 let common = FileDescriptorProto {
3369 name: Some("common.proto".into()),
3370 package: Some("common.v1".into()),
3371 message_type: vec![DescriptorProto {
3372 name: Some("Shared".into()),
3373 ..Default::default()
3374 }],
3375 ..Default::default()
3376 };
3377 let svc = minimal_file(
3378 Some("example.v1"),
3379 ".common.v1.Shared",
3380 ".example.v1.Out",
3381 &["Out"],
3382 );
3383 let code = gen_service(&[common, svc], 1, &[], false).unwrap();
3384
3385 assert!(
3388 code.contains("super :: super :: common :: v1 :: Shared"),
3389 "cross-package path not emitted: {code}"
3390 );
3391 assert!(
3392 code.contains("super :: super :: common :: v1 :: __buffa :: view :: SharedView"),
3393 "cross-package view path not emitted: {code}"
3394 );
3395 }
3396
3397 #[test]
3398 fn nested_message_view_type_mirrors_owned_module_nesting() {
3399 let file = FileDescriptorProto {
3404 name: Some("nested.proto".into()),
3405 package: Some("example.v1".into()),
3406 message_type: vec![
3407 DescriptorProto {
3408 name: Some("Outer".into()),
3409 nested_type: vec![DescriptorProto {
3410 name: Some("Inner".into()),
3411 ..Default::default()
3412 }],
3413 ..Default::default()
3414 },
3415 DescriptorProto {
3416 name: Some("Out".into()),
3417 ..Default::default()
3418 },
3419 ],
3420 service: vec![ServiceDescriptorProto {
3421 name: Some("NestedService".into()),
3422 method: vec![MethodDescriptorProto {
3423 name: Some("Ping".into()),
3424 input_type: Some(".example.v1.Outer.Inner".into()),
3425 output_type: Some(".example.v1.Out".into()),
3426 ..Default::default()
3427 }],
3428 ..Default::default()
3429 }],
3430 ..Default::default()
3431 };
3432 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3433
3434 assert!(
3435 code.contains("__buffa :: view :: outer :: InnerView"),
3436 "nested view path not emitted: {code}"
3437 );
3438 assert!(
3439 code.contains("outer :: Inner"),
3440 "nested owned path not emitted: {code}"
3441 );
3442 }
3443
3444 #[test]
3445 fn wkt_types_use_buffa_types_extern_path() {
3446 let wkt = FileDescriptorProto {
3450 name: Some("google/protobuf/empty.proto".into()),
3451 package: Some("google.protobuf".into()),
3452 message_type: vec![DescriptorProto {
3453 name: Some("Empty".into()),
3454 ..Default::default()
3455 }],
3456 ..Default::default()
3457 };
3458 let svc = minimal_file(
3459 Some("example.v1"),
3460 ".google.protobuf.Empty",
3461 ".example.v1.Out",
3462 &["Out"],
3463 );
3464 let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
3465
3466 assert!(
3467 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
3468 "WKT extern path not emitted: {code}"
3469 );
3470 }
3471
3472 #[test]
3473 fn extern_catchall_uses_absolute_paths() {
3474 let file = minimal_file(
3475 Some("example.v1"),
3476 ".example.v1.PingReq",
3477 ".example.v1.PingResp",
3478 &["PingReq", "PingResp"],
3479 );
3480 let extern_paths = [(".".into(), "crate::proto".into())];
3481 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
3482 assert!(
3483 code.contains("crate :: proto :: example :: v1 :: PingReq"),
3484 "owned type path missing: {code}"
3485 );
3486 assert!(
3487 code.contains("crate :: proto :: example :: v1 :: __buffa :: view :: PingReqView"),
3488 "view type path missing: {code}"
3489 );
3490 }
3491
3492 #[test]
3493 fn extern_catchall_with_wkt_longest_wins() {
3494 let wkt = FileDescriptorProto {
3497 name: Some("google/protobuf/empty.proto".into()),
3498 package: Some("google.protobuf".into()),
3499 message_type: vec![DescriptorProto {
3500 name: Some("Empty".into()),
3501 ..Default::default()
3502 }],
3503 ..Default::default()
3504 };
3505 let svc = minimal_file(
3506 Some("example.v1"),
3507 ".google.protobuf.Empty",
3508 ".example.v1.Out",
3509 &["Out"],
3510 );
3511 let extern_paths = [(".".into(), "crate::proto".into())];
3512 let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
3513 assert!(
3514 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
3515 "WKT mapping lost to catch-all: {code}"
3516 );
3517 assert!(
3518 code.contains("crate :: proto :: example :: v1 :: Out"),
3519 "local type not routed through catch-all: {code}"
3520 );
3521 }
3522
3523 #[test]
3524 fn missing_extern_path_errors() {
3525 let file = minimal_file(
3526 Some("example.v1"),
3527 ".example.v1.PingReq",
3528 ".example.v1.PingResp",
3529 &["PingReq", "PingResp"],
3530 );
3531 let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
3532 let msg = err.to_string();
3533 assert!(
3534 msg.contains("extern_path"),
3535 "error message lacks hint: {msg}"
3536 );
3537 }
3538
3539 #[test]
3540 fn missing_descriptor_type_errors_on_build_script_path() {
3541 let file = minimal_file(
3546 Some("example.v1"),
3547 ".dep.v1.Dep",
3548 ".example.v1.PingResp",
3549 &["PingResp"],
3550 );
3551 let err = generate_files(
3552 std::slice::from_ref(&file),
3553 &["ping.proto".into()],
3554 &Options::default(),
3555 )
3556 .expect_err("a dangling method type must not generate successfully");
3557 let msg = err.to_string();
3558 assert!(
3559 msg.contains(".dep.v1.Dep") && msg.contains("descriptor set"),
3560 "error message should name the missing type: {msg}"
3561 );
3562 assert!(
3563 msg.contains("--include_imports"),
3564 "error message should point at the precompiled-set fix: {msg}"
3565 );
3566 }
3567
3568 #[test]
3569 fn imported_type_outside_file_to_generate_still_resolves() {
3570 let wkt = FileDescriptorProto {
3575 name: Some("google/protobuf/empty.proto".into()),
3576 package: Some("google.protobuf".into()),
3577 message_type: vec![DescriptorProto {
3578 name: Some("Empty".into()),
3579 ..Default::default()
3580 }],
3581 ..Default::default()
3582 };
3583 let svc = minimal_file(
3584 Some("example.v1"),
3585 ".google.protobuf.Empty",
3586 ".example.v1.PingResp",
3587 &["PingResp"],
3588 );
3589 let generated = generate_files(&[wkt, svc], &["ping.proto".into()], &Options::default())
3590 .expect("an imported type present in the set resolves");
3591 let all: String = generated.iter().map(|f| f.content.as_str()).collect();
3592 assert!(
3593 all.contains("buffa_types :: google :: protobuf :: Empty")
3594 || all.contains("buffa_types::google::protobuf::Empty"),
3595 "imported WKT should resolve through its extern mapping: {all}"
3596 );
3597 }
3598
3599 #[test]
3600 fn missing_descriptor_type_on_plugin_path_omits_precompiled_hint() {
3601 let file = minimal_file(
3605 Some("example.v1"),
3606 ".dep.v1.Dep",
3607 ".example.v1.PingResp",
3608 &["PingResp"],
3609 );
3610 let extern_paths = [(".".into(), "crate::proto".into())];
3611 let err = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap_err();
3612 let msg = err.to_string();
3613 assert!(
3614 msg.contains(".dep.v1.Dep") && msg.contains("missing proto import"),
3615 "error message should name the missing type: {msg}"
3616 );
3617 assert!(
3618 !msg.contains("--include_imports"),
3619 "precompiled-set hint does not apply to the plugin path: {msg}"
3620 );
3621 }
3622
3623 #[test]
3624 fn keyword_package_escaped() {
3625 let file = minimal_file(
3627 Some("google.type"),
3628 ".google.type.LatLng",
3629 ".google.type.LatLng",
3630 &["LatLng"],
3631 );
3632 let extern_paths = [(".".into(), "crate::proto".into())];
3633 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
3634 assert!(
3635 code.contains("crate :: proto :: google :: r#type :: LatLng"),
3636 "keyword segment not escaped: {code}"
3637 );
3638 }
3639
3640 #[test]
3641 fn keyword_method_escaped() {
3642 let file = minimal_file_with_method(
3645 Some("example.v1"),
3646 "Move",
3647 ".example.v1.Empty",
3648 ".example.v1.Empty",
3649 &["Empty"],
3650 );
3651 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3652 assert!(
3653 code.contains("fn r#move"),
3654 "keyword method not escaped: {code}"
3655 );
3656 assert!(
3657 code.contains("move_with_options"),
3658 "suffixed variant should not need escaping: {code}"
3659 );
3660 assert!(code.contains("client.r#move(request)"));
3662 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3663 }
3664
3665 #[test]
3666 fn path_keyword_method_suffixed() {
3667 let file = minimal_file_with_method(
3670 Some("example.v1"),
3671 "Self",
3672 ".example.v1.Empty",
3673 ".example.v1.Empty",
3674 &["Empty"],
3675 );
3676 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3677 assert!(
3678 code.contains("fn self_"),
3679 "path-keyword method not suffixed: {code}"
3680 );
3681 assert!(code.contains("self_with_options"));
3685 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3686 }
3687
3688 #[test]
3689 fn service_name_keyword_suffixed() {
3690 let mut file = minimal_file(
3694 Some("example.v1"),
3695 ".example.v1.Empty",
3696 ".example.v1.Empty",
3697 &["Empty"],
3698 );
3699 file.service[0].name = Some("Self".into());
3700 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3701 assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
3702 assert!(code.contains("trait SelfExt"));
3703 assert!(code.contains("struct SelfClient"));
3704 assert!(code.contains("struct SelfServer"));
3705 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3706 }
3707
3708 #[test]
3709 fn method_snake_collision_errors() {
3710 let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
3713 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3714 let msg = err.to_string();
3715 assert!(msg.contains("PingService"), "missing service name: {msg}");
3716 assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
3717 assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
3718 assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
3719 }
3720
3721 #[test]
3722 fn method_with_options_collision_errors() {
3723 let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
3726 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3727 let msg = err.to_string();
3728 assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
3729 assert!(
3730 msg.contains("\"PingWithOptions\""),
3731 "missing second method: {msg}"
3732 );
3733 assert!(
3734 msg.contains("`ping_with_options`"),
3735 "missing rust ident: {msg}"
3736 );
3737 }
3738
3739 #[test]
3740 fn distinct_methods_do_not_collide() {
3741 let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
3742 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3743 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3744 }
3745
3746 #[test]
3747 fn options_default_buffa_config() {
3748 let cfg = Options::default().to_buffa_config();
3749 assert!(cfg.generate_json, "connectrpc enables JSON by default");
3750 assert!(cfg.generate_views);
3751 assert!(cfg.emit_register_fn);
3752 assert!(!cfg.strict_utf8_mapping);
3753 }
3754
3755 #[test]
3756 fn options_buffa_passthrough_forces_views() {
3757 let mut opts = Options::default();
3758 opts.buffa.emit_register_fn = false;
3759 opts.buffa.generate_views = false;
3760 let cfg = opts.to_buffa_config();
3761 assert!(!cfg.emit_register_fn);
3762 assert!(cfg.generate_views, "generate_views must be forced on");
3763 }
3764
3765 #[test]
3766 fn generate_files_emit_register_fn_false_suppresses_register_types() {
3767 let file = FileDescriptorProto {
3770 name: Some("ping.proto".into()),
3771 package: Some("example.v1".into()),
3772 message_type: vec![DescriptorProto {
3773 name: Some("PingReq".into()),
3774 ..Default::default()
3775 }],
3776 ..Default::default()
3777 };
3778
3779 let stitcher = |files: &[GeneratedFile]| {
3782 files
3783 .iter()
3784 .find(|f| f.kind == GeneratedFileKind::PackageMod)
3785 .expect("PackageMod file emitted")
3786 .content
3787 .clone()
3788 };
3789
3790 let with_fn = generate_files(
3791 std::slice::from_ref(&file),
3792 &["ping.proto".into()],
3793 &Options::default(),
3794 )
3795 .unwrap();
3796 let mod_rs = stitcher(&with_fn);
3797 assert!(
3798 mod_rs.contains("fn register_types"),
3799 "expected register_types in default output: {mod_rs}"
3800 );
3801
3802 let mut opts = Options::default();
3803 opts.buffa.emit_register_fn = false;
3804 let without_fn =
3805 generate_files(std::slice::from_ref(&file), &["ping.proto".into()], &opts).unwrap();
3806 let mod_rs = stitcher(&without_fn);
3807 assert!(
3808 !mod_rs.contains("fn register_types"),
3809 "register_types should be suppressed: {mod_rs}"
3810 );
3811 }
3812
3813 #[test]
3814 fn plugin_no_register_fn_parses() {
3815 let request = CodeGeneratorRequest {
3816 parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
3817 file_to_generate: vec![],
3818 proto_file: vec![],
3819 ..Default::default()
3820 };
3821 generate(&request).expect("no_register_fn should be a recognized plugin option");
3824 }
3825
3826 fn format_minimal_service(gate_client_feature: bool) -> String {
3831 format_minimal_service_with_client_feature_name(gate_client_feature, "client")
3832 }
3833
3834 fn format_minimal_service_with_client_feature_name(
3835 gate_client_feature: bool,
3836 client_feature_name: &str,
3837 ) -> String {
3838 let file = minimal_file(
3839 Some("example.v1"),
3840 ".example.v1.PingReq",
3841 ".example.v1.PingResp",
3842 &["PingReq", "PingResp"],
3843 );
3844 let config = buffa_codegen::CodeGenConfig::default();
3845 let target = file.name.clone().into_iter().collect::<Vec<_>>();
3846 let resolver = TypeResolver::new(std::slice::from_ref(&file), &target, &config, false);
3847 let service = &file.service[0];
3848 let batch = BatchState {
3849 colliding_aliases: collect_alias_collisions(std::slice::from_ref(&file), &target),
3850 gate_client_feature,
3851 client_feature_name: client_feature_name.to_string(),
3852 ..BatchState::default()
3853 };
3854 format_token_stream(&generate_service(&file, service, &resolver, &batch).unwrap()).unwrap()
3855 }
3856
3857 #[test]
3858 fn default_emission_has_no_client_cfg() {
3859 let out = format_minimal_service(false);
3863 assert!(
3864 !out.contains("#[cfg(feature ="),
3865 "default emission must not emit any cfg attr — external \
3866 consumers should not need to declare a `client` Cargo \
3867 feature unless they explicitly opt in via the \
3868 `gate_client_feature` plugin option:\n{out}"
3869 );
3870 }
3871
3872 #[test]
3873 fn client_items_gated_when_opt_in() {
3874 let out = format_minimal_service(true);
3879 let cfg_count = out.matches("#[cfg(feature = \"client\")]").count();
3880 assert_eq!(
3881 cfg_count, 2,
3882 "expected exactly two #[cfg(feature = \"client\")] attrs (one on \
3883 `pub struct PingServiceClient`, one on its `impl<T>` block); got \
3884 {cfg_count}:\n{out}"
3885 );
3886 }
3887
3888 #[test]
3889 fn client_items_use_custom_feature_name_when_configured() {
3890 let out = format_minimal_service_with_client_feature_name(true, "grpc-client");
3891 let cfg_count = out.matches("#[cfg(feature = \"grpc-client\")]").count();
3892 assert_eq!(
3893 cfg_count, 2,
3894 "expected exactly two #[cfg(feature = \"grpc-client\")] attrs \
3895 (one on `pub struct PingServiceClient`, one on its `impl<T>` \
3896 block); got {cfg_count}:\n{out}"
3897 );
3898 assert!(
3899 !out.contains("#[cfg(feature = \"client\")]"),
3900 "custom feature name must replace the default `client` gate:\n{out}"
3901 );
3902 }
3903
3904 #[test]
3905 fn server_items_never_carry_client_cfg() {
3906 let out = format_minimal_service(true);
3910 for marker in [
3911 "pub trait PingService",
3912 "pub trait PingServiceExt",
3913 "pub struct PingServiceRegisterMarker",
3914 "pub struct PingServiceServer",
3915 "pub const PING_SERVICE_SERVICE_NAME",
3916 ] {
3917 let idx = out
3918 .find(marker)
3919 .unwrap_or_else(|| panic!("expected `{marker}` in output:\n{out}"));
3920 let prefix = &out[..idx];
3921 assert!(
3922 !prefix.trim_end().ends_with("#[cfg(feature = \"client\")]"),
3923 "`{marker}` must not be preceded by a client cfg attr — \
3924 server-side items are always compiled in:\n{out}"
3925 );
3926 }
3927 }
3928
3929 #[test]
3930 fn service_register_impl_and_marker_are_generated() {
3931 let out = format_minimal_service(false);
3932 assert!(
3933 out.contains("pub struct PingServiceRegisterMarker;"),
3934 "generated service must expose an inference marker:\n{out}"
3935 );
3936 assert!(
3937 out.contains(
3938 "impl<S: PingService> ::connectrpc::ServiceRegister<PingServiceRegisterMarker>"
3939 ),
3940 "generated service must implement ServiceRegister for Arc<S>:\n{out}"
3941 );
3942 assert!(
3943 out.contains("for ::std::sync::Arc<S>"),
3944 "ServiceRegister implementation must accept Arc<S>:\n{out}"
3945 );
3946 assert!(
3947 out.contains("fn register_service(self, router: ::connectrpc::Router)"),
3948 "ServiceRegister implementation must expose the bridge method:\n{out}"
3949 );
3950 assert!(
3951 out.contains("<S as PingServiceExt>::register(self, router)"),
3952 "ServiceRegister must forward to the existing extension trait:\n{out}"
3953 );
3954 }
3955
3956 #[test]
3970 fn no_ungated_client_references() {
3971 let out = format_minimal_service(true);
3975 let parsed: syn::File = syn::parse_str(&out).expect("output parses");
3976
3977 let mut offenders: Vec<String> = Vec::new();
3978 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
3979 assert!(
3980 offenders.is_empty(),
3981 "every item that mentions `::connectrpc::client::*` must be \
3982 prefixed with `#[cfg(feature = \"client\")]`. Offenders:\n{}\n\nFull output:\n{out}",
3983 offenders.join("\n")
3984 );
3985 }
3986
3987 fn is_client_feature_cfg(attr: &syn::Attribute) -> bool {
3991 attr.path().is_ident("cfg")
3992 && attr
3993 .to_token_stream()
3994 .to_string()
3995 .contains("feature = \"client\"")
3996 }
3997
3998 fn mentions_connectrpc_client(ts: TokenStream) -> bool {
4003 let rendered = format_token_stream(&ts).unwrap_or_default();
4004 rendered.contains("::connectrpc::client::") || rendered.contains("connectrpc :: client ::")
4005 }
4006
4007 fn scan_items_for_ungated_client_refs(
4024 items: &[syn::Item],
4025 ancestor_gated: bool,
4026 offenders: &mut Vec<String>,
4027 ) {
4028 for item in items {
4029 let (attrs, ident): (&[syn::Attribute], String) = match item {
4035 syn::Item::Struct(s) => (&s.attrs, s.ident.to_string()),
4036 syn::Item::Impl(i) => (
4037 &i.attrs,
4038 format!("impl-block for {}", ToTokens::to_token_stream(&i.self_ty)),
4039 ),
4040 syn::Item::Fn(f) => (&f.attrs, f.sig.ident.to_string()),
4041 syn::Item::Trait(t) => (&t.attrs, t.ident.to_string()),
4042 syn::Item::Const(c) => (&c.attrs, c.ident.to_string()),
4043 syn::Item::Type(t) => (&t.attrs, t.ident.to_string()),
4044 syn::Item::Static(s) => (&s.attrs, s.ident.to_string()),
4045 syn::Item::Use(u) => (&u.attrs, "use-item".to_string()),
4046 syn::Item::ExternCrate(e) => (&e.attrs, e.ident.to_string()),
4047 syn::Item::Macro(m) => (
4048 &m.attrs,
4049 m.ident
4050 .as_ref()
4051 .map(syn::Ident::to_string)
4052 .unwrap_or_else(|| "macro-item".to_string()),
4053 ),
4054 syn::Item::ForeignMod(f) => (&f.attrs, "extern-block".to_string()),
4055 syn::Item::Union(u) => (&u.attrs, u.ident.to_string()),
4056 syn::Item::TraitAlias(t) => (&t.attrs, t.ident.to_string()),
4057 syn::Item::Enum(e) => (&e.attrs, e.ident.to_string()),
4058 syn::Item::Mod(m) => {
4059 let self_gated = m.attrs.iter().any(is_client_feature_cfg);
4060 let gated = ancestor_gated || self_gated;
4061 if let Some((_brace, children)) = &m.content {
4062 scan_items_for_ungated_client_refs(children, gated, offenders);
4063 }
4064 continue;
4067 }
4068 _ => (&[][..], "<unrecognized item>".to_string()),
4072 };
4073 let self_gated = attrs.iter().any(is_client_feature_cfg);
4074 let gated = ancestor_gated || self_gated;
4075 if gated {
4076 continue;
4077 }
4078 if mentions_connectrpc_client(ToTokens::to_token_stream(item)) {
4079 offenders.push(format!(
4080 "ungated reference to ::connectrpc::client in `{ident}`"
4081 ));
4082 }
4083 }
4084 }
4085
4086 #[test]
4091 fn ungated_scanner_handles_nested_modules() {
4092 let parsed: syn::File = syn::parse_str(
4094 r#"
4095 #[cfg(feature = "client")]
4096 pub mod gated_parent {
4097 pub struct WithClientRef {
4098 field: ::connectrpc::client::ClientConfig,
4099 }
4100 }
4101 "#,
4102 )
4103 .unwrap();
4104 let mut offenders = Vec::new();
4105 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4106 assert!(
4107 offenders.is_empty(),
4108 "parent-level cfg must cover children: {offenders:?}"
4109 );
4110
4111 let parsed: syn::File = syn::parse_str(
4114 r#"
4115 pub mod ungated_parent {
4116 pub struct WithClientRef {
4117 field: ::connectrpc::client::ClientConfig,
4118 }
4119 }
4120 "#,
4121 )
4122 .unwrap();
4123 let mut offenders = Vec::new();
4124 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4125 assert_eq!(
4126 offenders.len(),
4127 1,
4128 "exactly one offender expected (the inner struct), not the wrapping \
4129 module: {offenders:?}"
4130 );
4131 assert!(
4132 offenders[0].contains("WithClientRef"),
4133 "offender should name the inner struct: {:?}",
4134 offenders[0]
4135 );
4136
4137 let parsed: syn::File = syn::parse_str(
4139 r#"
4140 pub mod outer {
4141 #[cfg(feature = "client")]
4142 pub struct GatedClient {
4143 field: ::connectrpc::client::ClientConfig,
4144 }
4145 }
4146 "#,
4147 )
4148 .unwrap();
4149 let mut offenders = Vec::new();
4150 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4151 assert!(
4152 offenders.is_empty(),
4153 "self-gating child inside ungated module must be OK: {offenders:?}"
4154 );
4155 }
4156
4157 #[test]
4164 fn ungated_scanner_catches_use_and_static_items() {
4165 let parsed: syn::File = syn::parse_str("use ::connectrpc::client::ClientConfig;").unwrap();
4167 let mut offenders = Vec::new();
4168 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4169 assert_eq!(
4170 offenders.len(),
4171 1,
4172 "ungated `use ::connectrpc::client::*` must be flagged: {offenders:?}"
4173 );
4174
4175 let parsed: syn::File =
4177 syn::parse_str("#[cfg(feature = \"client\")] use ::connectrpc::client::ClientConfig;")
4178 .unwrap();
4179 let mut offenders = Vec::new();
4180 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4181 assert!(
4182 offenders.is_empty(),
4183 "gated `use ::connectrpc::client::*` must NOT be flagged: {offenders:?}"
4184 );
4185
4186 let parsed: syn::File =
4188 syn::parse_str("static FOO: &str = stringify!(::connectrpc::client::ClientConfig);")
4189 .unwrap();
4190 let mut offenders = Vec::new();
4191 scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4192 assert_eq!(
4193 offenders.len(),
4194 1,
4195 "ungated `static FOO` mentioning ::connectrpc::client must be flagged: \
4196 {offenders:?}"
4197 );
4198 }
4199
4200 #[test]
4201 fn client_cfg_round_trips_through_prettyplease() {
4202 let out = format_minimal_service(true);
4208 assert!(
4211 out.contains("#[cfg(feature = \"client\")]"),
4212 "prettyplease no longer renders the cfg attr as expected; \
4213 update the grep pattern in client_items_always_gated:\n{out}"
4214 );
4215 }
4216
4217 #[test]
4218 fn multi_service_in_one_file_each_client_is_gated() {
4219 let make_service = |name: &str| ServiceDescriptorProto {
4223 name: Some(name.into()),
4224 method: vec![MethodDescriptorProto {
4225 name: Some("Ping".into()),
4226 input_type: Some(".example.v1.PingReq".into()),
4227 output_type: Some(".example.v1.PingResp".into()),
4228 ..Default::default()
4229 }],
4230 ..Default::default()
4231 };
4232 let file = FileDescriptorProto {
4233 name: Some("two.proto".into()),
4234 package: Some("example.v1".into()),
4235 service: vec![make_service("Alpha"), make_service("Beta")],
4236 message_type: vec![
4237 DescriptorProto {
4238 name: Some("PingReq".into()),
4239 ..Default::default()
4240 },
4241 DescriptorProto {
4242 name: Some("PingResp".into()),
4243 ..Default::default()
4244 },
4245 ],
4246 ..Default::default()
4247 };
4248 let config = buffa_codegen::CodeGenConfig::default();
4249 let target = vec!["two.proto".to_string()];
4250 let resolver = TypeResolver::new(std::slice::from_ref(&file), &target, &config, false);
4251 let mut batch = BatchState {
4252 colliding_aliases: collect_alias_collisions(std::slice::from_ref(&file), &target),
4253 gate_client_feature: true,
4254 ..BatchState::default()
4255 };
4256 let ts = generate_connect_services(&file, &resolver, &mut batch).unwrap();
4257 let out = format_token_stream(&ts).unwrap();
4258 let cfg_count = out.matches("#[cfg(feature = \"client\")]").count();
4259 assert_eq!(
4260 cfg_count, 4,
4261 "expected 4 client cfg attrs (2 per service * 2 services); got \
4262 {cfg_count}:\n{out}"
4263 );
4264 for client_struct in ["pub struct AlphaClient", "pub struct BetaClient"] {
4266 let idx = out
4267 .find(client_struct)
4268 .unwrap_or_else(|| panic!("expected `{client_struct}` in output:\n{out}"));
4269 let prefix = &out[..idx];
4270 assert!(
4271 prefix.trim_end().ends_with("#[derive(Clone)]")
4272 || prefix.contains("#[cfg(feature = \"client\")]"),
4273 "`{client_struct}` must have a client cfg attr in its \
4274 attribute cluster:\n{out}"
4275 );
4276 }
4277 }
4278
4279 #[test]
4280 fn plugin_accepts_gate_client_feature_flag() {
4281 let request = CodeGeneratorRequest {
4283 parameter: Some("buffa_module=crate::proto,gate_client_feature".into()),
4284 file_to_generate: vec![],
4285 proto_file: vec![],
4286 ..Default::default()
4287 };
4288 generate(&request).expect("gate_client_feature should be a recognized plugin option");
4289 }
4290
4291 #[test]
4292 fn plugin_accepts_gate_client_feature_value_form() {
4293 let file = minimal_file(
4294 Some("example.v1"),
4295 ".example.v1.PingReq",
4296 ".example.v1.PingResp",
4297 &["PingReq", "PingResp"],
4298 );
4299 let request = CodeGeneratorRequest {
4300 parameter: Some("buffa_module=crate::proto,gate_client_feature=grpc-client".into()),
4301 file_to_generate: vec!["ping.proto".into()],
4302 proto_file: vec![file],
4303 ..Default::default()
4304 };
4305 let response =
4306 generate(&request).expect("custom gate_client_feature value should be recognized");
4307 let connect_file = response
4308 .file
4309 .iter()
4310 .find(|f| f.name.as_deref() == Some("ping.__connect.rs"))
4311 .expect("plugin should emit a connect service companion");
4312 let content = connect_file.content.as_deref().unwrap_or_default();
4313 let cfg_count = content.matches("#[cfg(feature = \"grpc-client\")]").count();
4314 assert_eq!(
4315 cfg_count, 2,
4316 "expected custom feature gate on generated client struct and impl; got \
4317 {cfg_count}:\n{content}"
4318 );
4319 assert!(
4320 !content.contains("#[cfg(feature = \"client\")]"),
4321 "custom plugin feature name must replace the default gate:\n{content}"
4322 );
4323 }
4324
4325 #[test]
4326 fn plugin_rejects_empty_gate_client_feature_value() {
4327 let request = CodeGeneratorRequest {
4328 parameter: Some("buffa_module=crate::proto,gate_client_feature=".into()),
4329 file_to_generate: vec![],
4330 proto_file: vec![],
4331 ..Default::default()
4332 };
4333 let err = generate(&request).expect_err("empty gate_client_feature value must be rejected");
4334 let msg = err.to_string();
4335 assert!(
4336 msg.contains("gate_client_feature requires a non-empty feature name"),
4337 "error should describe the empty feature-name problem: {msg}"
4338 );
4339 }
4340
4341 #[test]
4342 fn options_reject_invalid_client_feature_name() {
4343 let opts = Options {
4344 gate_client_feature: true,
4345 client_feature_name: "grpc client".into(),
4346 ..Options::default()
4347 };
4348 let err = generate_services(&[], &[], &opts)
4349 .expect_err("invalid client feature name must be rejected");
4350 assert!(
4351 err.to_string().contains("not a valid Cargo feature name"),
4352 "error should name the grammar problem: {err}"
4353 );
4354 }
4355
4356 fn all_messages_options(extra_extern: &[(&str, &str)]) -> Options {
4360 let mut options = Options {
4361 encodable_impls: EncodableImpls::AllMessages,
4362 ..Options::default()
4363 };
4364 for (proto, rust) in extra_extern {
4365 options
4366 .buffa
4367 .extern_paths
4368 .push(((*proto).into(), (*rust).into()));
4369 }
4370 options
4371 .buffa
4372 .extern_paths
4373 .push((".".into(), "crate::proto".into()));
4374 options
4375 }
4376
4377 #[test]
4378 fn all_messages_emits_impls_for_serviceless_proto() {
4379 let file = FileDescriptorProto {
4380 name: Some("common.proto".into()),
4381 package: Some("common.v1".into()),
4382 message_type: vec![DescriptorProto {
4383 name: Some("Shared".into()),
4384 ..Default::default()
4385 }],
4386 ..Default::default()
4387 };
4388 let generated = generate_services(
4389 std::slice::from_ref(&file),
4390 &["common.proto".into()],
4391 &all_messages_options(&[]),
4392 )
4393 .unwrap();
4394
4395 let companion = generated
4396 .iter()
4397 .find(|f| f.name == "common.__connect.rs")
4398 .expect("service-less proto must get a companion under all_messages");
4399 assert_eq!(
4400 companion
4401 .content
4402 .matches("impl ::connectrpc::Encodable<")
4403 .count(),
4404 2,
4405 "one view + one OwnedView impl: {}",
4406 companion.content
4407 );
4408 assert!(
4409 companion.content.contains("SharedView"),
4410 "impls must target the view type: {}",
4411 companion.content
4412 );
4413 let stitcher = generated
4415 .iter()
4416 .find(|f| f.kind == GeneratedFileKind::PackageMod)
4417 .expect("package stitcher for the companion");
4418 assert!(
4419 stitcher
4420 .content
4421 .contains("include!(\"common.__connect.rs\")"),
4422 "stitcher must include the companion: {}",
4423 stitcher.content
4424 );
4425 }
4426
4427 #[test]
4428 fn all_messages_skips_extern_mapped_proto_entirely() {
4429 let file = FileDescriptorProto {
4432 name: Some("common.proto".into()),
4433 package: Some("common.v1".into()),
4434 message_type: vec![DescriptorProto {
4435 name: Some("Shared".into()),
4436 ..Default::default()
4437 }],
4438 ..Default::default()
4439 };
4440 let generated = generate_services(
4441 std::slice::from_ref(&file),
4442 &["common.proto".into()],
4443 &all_messages_options(&[(".common.v1", "::common_protos::proto::common::v1")]),
4444 )
4445 .unwrap();
4446 assert!(
4447 generated.is_empty(),
4448 "foreign-mapped proto must produce no files: {:?}",
4449 generated.iter().map(|f| &f.name).collect::<Vec<_>>()
4450 );
4451 }
4452
4453 #[test]
4454 fn all_messages_dedups_with_service_output_impls() {
4455 let file = minimal_file(
4459 Some("example.v1"),
4460 ".example.v1.PingReq",
4461 ".example.v1.PingResp",
4462 &["PingReq", "PingResp"],
4463 );
4464 let generated = generate_services(
4465 std::slice::from_ref(&file),
4466 &["ping.proto".into()],
4467 &all_messages_options(&[]),
4468 )
4469 .unwrap();
4470 let companion = generated
4471 .iter()
4472 .find(|f| f.name == "ping.__connect.rs")
4473 .expect("service companion");
4474 assert_eq!(
4480 companion.content.matches("encode_view_body(").count(),
4481 4,
4482 "exactly one impl pair per message (PingReq + PingResp), \
4483 no E0119 duplicates: {}",
4484 companion.content
4485 );
4486 assert!(companion.content.contains("PingReqView"));
4487 assert!(companion.content.contains("PingRespView"));
4488 }
4489
4490 #[test]
4491 fn all_messages_recurses_nested_and_skips_map_entries() {
4492 use buffa_codegen::generated::descriptor::MessageOptions;
4493 let file = FileDescriptorProto {
4494 name: Some("nested.proto".into()),
4495 package: Some("example.v1".into()),
4496 message_type: vec![DescriptorProto {
4497 name: Some("Outer".into()),
4498 nested_type: vec![
4499 DescriptorProto {
4500 name: Some("Inner".into()),
4501 ..Default::default()
4502 },
4503 DescriptorProto {
4504 name: Some("LabelsEntry".into()),
4505 options: buffa::MessageField::some(MessageOptions {
4506 map_entry: Some(true),
4507 ..Default::default()
4508 }),
4509 ..Default::default()
4510 },
4511 ],
4512 ..Default::default()
4513 }],
4514 ..Default::default()
4515 };
4516 let generated = generate_services(
4517 std::slice::from_ref(&file),
4518 &["nested.proto".into()],
4519 &all_messages_options(&[]),
4520 )
4521 .unwrap();
4522 let companion = generated
4523 .iter()
4524 .find(|f| f.name == "nested.__connect.rs")
4525 .expect("companion with nested-message impls");
4526 assert_eq!(
4527 companion
4528 .content
4529 .matches("impl ::connectrpc::Encodable<")
4530 .count(),
4531 4,
4532 "impl pairs for Outer and Outer.Inner only: {}",
4533 companion.content
4534 );
4535 assert!(
4536 companion.content.contains("InnerView"),
4537 "nested message must get impls: {}",
4538 companion.content
4539 );
4540 assert!(
4541 !companion.content.contains("LabelsEntry"),
4542 "synthetic map entries must not get impls: {}",
4543 companion.content
4544 );
4545 }
4546
4547 #[test]
4548 fn all_messages_file_per_package_collapses_serviceless_output() {
4549 let file = FileDescriptorProto {
4553 name: Some("common.proto".into()),
4554 package: Some("common.v1".into()),
4555 message_type: vec![DescriptorProto {
4556 name: Some("Shared".into()),
4557 ..Default::default()
4558 }],
4559 ..Default::default()
4560 };
4561 let mut options = all_messages_options(&[]);
4562 options.buffa.file_per_package = true;
4563 let generated = generate_services(
4564 std::slice::from_ref(&file),
4565 &["common.proto".into()],
4566 &options,
4567 )
4568 .unwrap();
4569 assert_eq!(generated.len(), 1, "exactly one PackageMod file");
4570 let pkg_mod = &generated[0];
4571 assert_eq!(pkg_mod.kind, GeneratedFileKind::PackageMod);
4572 assert_eq!(pkg_mod.name, "common.v1.rs");
4573 assert_eq!(
4574 pkg_mod.content.matches("encode_view_body(").count(),
4575 2,
4576 "impl pair inlined into the package file: {}",
4577 pkg_mod.content
4578 );
4579 }
4580
4581 #[test]
4582 fn generate_files_all_messages_file_per_package_inlines_serviceless_impls() {
4583 let file = FileDescriptorProto {
4588 name: Some("common.proto".into()),
4589 package: Some("common.v1".into()),
4590 message_type: vec![DescriptorProto {
4591 name: Some("Shared".into()),
4592 ..Default::default()
4593 }],
4594 ..Default::default()
4595 };
4596 let mut options = Options {
4597 encodable_impls: EncodableImpls::AllMessages,
4598 ..Options::default()
4599 };
4600 options.buffa.file_per_package = true;
4601 let generated = generate_files(
4602 std::slice::from_ref(&file),
4603 &["common.proto".into()],
4604 &options,
4605 )
4606 .unwrap();
4607 assert!(
4608 !generated
4609 .iter()
4610 .any(|f| f.kind == GeneratedFileKind::Companion),
4611 "file_per_package must not leave sibling Companion files"
4612 );
4613 let pkg_mod = generated
4614 .iter()
4615 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "common.v1")
4616 .expect("PackageMod for common.v1");
4617 assert_eq!(
4618 pkg_mod.content.matches("encode_view_body(").count(),
4619 2,
4620 "impl pair inlined into the package file: {}",
4621 pkg_mod.content
4622 );
4623 }
4624
4625 #[test]
4626 fn plugin_accepts_encodable_impls_option() {
4627 let file = FileDescriptorProto {
4628 name: Some("common.proto".into()),
4629 package: Some("common.v1".into()),
4630 message_type: vec![DescriptorProto {
4631 name: Some("Shared".into()),
4632 ..Default::default()
4633 }],
4634 ..Default::default()
4635 };
4636 let request = CodeGeneratorRequest {
4637 parameter: Some("buffa_module=crate::proto,encodable_impls=all_messages".into()),
4638 file_to_generate: vec!["common.proto".into()],
4639 proto_file: vec![file],
4640 ..Default::default()
4641 };
4642 let response = generate(&request).expect("encodable_impls=all_messages is recognized");
4643 let companion = response
4644 .file
4645 .iter()
4646 .find(|f| f.name.as_deref() == Some("common.__connect.rs"))
4647 .expect("plugin should emit impls for a service-less proto");
4648 assert!(
4649 companion
4650 .content
4651 .as_deref()
4652 .unwrap_or_default()
4653 .contains("impl ::connectrpc::Encodable<"),
4654 );
4655 }
4656
4657 #[test]
4658 fn plugin_rejects_invalid_encodable_impls_value() {
4659 let request = CodeGeneratorRequest {
4660 parameter: Some("buffa_module=crate::proto,encodable_impls=bogus".into()),
4661 file_to_generate: vec![],
4662 proto_file: vec![],
4663 ..Default::default()
4664 };
4665 let err = generate(&request).expect_err("bogus encodable_impls value must be rejected");
4666 let msg = err.to_string();
4667 assert!(
4668 msg.contains("invalid encodable_impls value"),
4669 "error should describe the bad value: {msg}"
4670 );
4671 }
4672
4673 #[test]
4674 fn generate_files_all_messages_wires_serviceless_companion() {
4675 let file = FileDescriptorProto {
4678 name: Some("common.proto".into()),
4679 package: Some("common.v1".into()),
4680 message_type: vec![DescriptorProto {
4681 name: Some("Shared".into()),
4682 ..Default::default()
4683 }],
4684 ..Default::default()
4685 };
4686 let options = Options {
4687 encodable_impls: EncodableImpls::AllMessages,
4688 ..Options::default()
4689 };
4690 let generated = generate_files(
4691 std::slice::from_ref(&file),
4692 &["common.proto".into()],
4693 &options,
4694 )
4695 .unwrap();
4696 let companion = generated
4697 .iter()
4698 .find(|f| f.kind == GeneratedFileKind::Companion)
4699 .expect("companion for service-less proto in unified mode");
4700 assert_eq!(
4701 companion
4702 .content
4703 .matches("impl ::connectrpc::Encodable<")
4704 .count(),
4705 2
4706 );
4707 let stitcher = generated
4708 .iter()
4709 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "common.v1")
4710 .expect("package stitcher");
4711 assert!(
4712 stitcher
4713 .content
4714 .contains(&format!("include!(\"{}\")", companion.name)),
4715 "stitcher must include the companion: {}",
4716 stitcher.content
4717 );
4718 }
4719
4720 #[test]
4721 fn plugin_rejects_old_client_feature_value_form() {
4722 let request = CodeGeneratorRequest {
4728 parameter: Some("buffa_module=crate::proto,client_feature=client".into()),
4729 file_to_generate: vec![],
4730 proto_file: vec![],
4731 ..Default::default()
4732 };
4733 let err = generate(&request)
4734 .expect_err("legacy `client_feature=…` option must now fail as unknown");
4735 let msg = err.to_string();
4736 assert!(
4737 msg.contains("client_feature"),
4738 "error should name the offending option: {msg}"
4739 );
4740 assert!(
4741 msg.contains("unknown plugin option"),
4742 "error should say the option is unknown: {msg}"
4743 );
4744 }
4745
4746 #[test]
4751 fn plugin_accepts_the_element_memory_limit_option_it_consumed_pre_decode() {
4752 for value in ["unlimited", "max", "2147483648"] {
4753 let request = CodeGeneratorRequest {
4754 parameter: Some(format!(
4755 "buffa_module=crate::proto,{}={value}",
4756 buffa_codegen::ELEMENT_MEMORY_LIMIT_OPT
4757 )),
4758 file_to_generate: vec![],
4759 proto_file: vec![],
4760 ..Default::default()
4761 };
4762 generate(&request).unwrap_or_else(|e| {
4763 panic!("element_memory_limit={value} must not reach the unknown-option arm: {e}")
4764 });
4765 }
4766 }
4767
4768 #[test]
4769 fn plugin_file_per_package_collapses_output() {
4770 let request = CodeGeneratorRequest {
4773 parameter: Some("buffa_module=crate::proto,file_per_package".into()),
4774 file_to_generate: vec!["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
4775 proto_file: file_per_package_fixture(),
4776 ..Default::default()
4777 };
4778 let response = generate(&request).expect("file_per_package should parse and generate");
4779 let mut names: Vec<&str> = response
4780 .file
4781 .iter()
4782 .filter_map(|f| f.name.as_deref())
4783 .collect();
4784 names.sort_unstable();
4785 assert_eq!(
4786 names,
4787 ["a.v1.rs", "b.v1.rs"],
4788 "expected one file per package: {names:?}"
4789 );
4790 for f in &response.file {
4791 let content = f.content.as_deref().unwrap_or_default();
4792 assert!(
4793 !content.contains("include!"),
4794 "file_per_package output must be self-contained: {content}"
4795 );
4796 }
4797 }
4798
4799 #[test]
4800 fn no_top_level_use_statements_in_generated_code() {
4801 let file = minimal_file(
4805 Some("example.v1"),
4806 ".example.v1.PingReq",
4807 ".example.v1.PingResp",
4808 &["PingReq", "PingResp"],
4809 );
4810 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
4811 let formatted = format_token_stream(&code.parse::<TokenStream>().unwrap()).unwrap();
4812 assert_no_top_level_use(&formatted, "generated code");
4813 }
4814
4815 #[test]
4816 fn multi_service_include_no_e0252() {
4817 let file_a = {
4820 let method = MethodDescriptorProto {
4821 name: Some("Ping".into()),
4822 input_type: Some(".svc.v1.PingReq".into()),
4823 output_type: Some(".svc.v1.PingResp".into()),
4824 ..Default::default()
4825 };
4826 let service = ServiceDescriptorProto {
4827 name: Some("Alpha".into()),
4828 method: vec![method],
4829 ..Default::default()
4830 };
4831 FileDescriptorProto {
4832 name: Some("alpha.proto".into()),
4833 package: Some("svc.v1".into()),
4834 service: vec![service],
4835 message_type: vec![
4836 DescriptorProto {
4837 name: Some("PingReq".into()),
4838 ..Default::default()
4839 },
4840 DescriptorProto {
4841 name: Some("PingResp".into()),
4842 ..Default::default()
4843 },
4844 ],
4845 ..Default::default()
4846 }
4847 };
4848 let file_b = {
4849 let method = MethodDescriptorProto {
4850 name: Some("Pong".into()),
4851 input_type: Some(".svc.v1.PongReq".into()),
4852 output_type: Some(".svc.v1.PongResp".into()),
4853 ..Default::default()
4854 };
4855 let service = ServiceDescriptorProto {
4856 name: Some("Beta".into()),
4857 method: vec![method],
4858 ..Default::default()
4859 };
4860 FileDescriptorProto {
4861 name: Some("beta.proto".into()),
4862 package: Some("svc.v1".into()),
4863 service: vec![service],
4864 message_type: vec![
4865 DescriptorProto {
4866 name: Some("PongReq".into()),
4867 ..Default::default()
4868 },
4869 DescriptorProto {
4870 name: Some("PongResp".into()),
4871 ..Default::default()
4872 },
4873 ],
4874 ..Default::default()
4875 }
4876 };
4877
4878 let files = vec![file_a, file_b];
4879 let config = buffa_codegen::CodeGenConfig::default();
4880 let targets = vec!["alpha.proto".to_string(), "beta.proto".to_string()];
4881 let resolver = TypeResolver::new(&files, &targets, &config, false);
4882
4883 let mut batch = BatchState {
4884 colliding_aliases: collect_alias_collisions(&files, &targets),
4885 ..BatchState::default()
4886 };
4887 let code_a = generate_connect_services(&files[0], &resolver, &mut batch).unwrap();
4888 let code_b = generate_connect_services(&files[1], &resolver, &mut batch).unwrap();
4889
4890 let formatted_a = format_token_stream(&code_a).unwrap();
4891 let formatted_b = format_token_stream(&code_b).unwrap();
4892
4893 syn::parse_str::<syn::File>(&formatted_a).expect("service A should parse independently");
4895 syn::parse_str::<syn::File>(&formatted_b).expect("service B should parse independently");
4896
4897 let combined = format!("{formatted_a}\n{formatted_b}");
4899 syn::parse_str::<syn::File>(&combined)
4900 .expect("combined services should parse without E0252");
4901
4902 assert_no_top_level_use(&formatted_a, "service A");
4904 assert_no_top_level_use(&formatted_b, "service B");
4905 }
4906
4907 #[test]
4911 fn generate_spec_consts_per_method() {
4912 use buffa_codegen::generated::descriptor::MethodOptions;
4913
4914 let m = |name: &str, cs: bool, ss: bool, idem: Option<IdempotencyLevel>| {
4915 MethodDescriptorProto {
4916 name: Some(name.into()),
4917 input_type: Some(".pkg.Req".into()),
4918 output_type: Some(".pkg.Resp".into()),
4919 client_streaming: Some(cs),
4920 server_streaming: Some(ss),
4921 options: MethodOptions {
4922 idempotency_level: idem,
4923 ..Default::default()
4924 }
4925 .into(),
4926 ..Default::default()
4927 }
4928 };
4929 let service = ServiceDescriptorProto {
4930 name: Some("EchoService".into()),
4931 method: vec![
4932 m("Say", false, false, Some(IdempotencyLevel::NO_SIDE_EFFECTS)),
4933 m("Subscribe", false, true, Some(IdempotencyLevel::IDEMPOTENT)),
4934 m("Upload", true, false, None),
4935 m("Chat", true, true, None),
4936 ],
4937 ..Default::default()
4938 };
4939
4940 assert_eq!(
4942 method_spec_const_ident(&service, "Say").to_string(),
4943 "ECHO_SERVICE_SAY_SPEC"
4944 );
4945
4946 let consts = generate_spec_consts("pkg.EchoService", &service);
4947 assert_eq!(consts.len(), 4, "one const per method");
4948
4949 let render = |ts: &TokenStream| {
4950 let file = syn::parse2::<syn::File>(ts.clone()).expect("const should parse");
4951 prettyplease::unparse(&file)
4952 };
4953 let say = render(&consts[0]);
4954 assert!(say.contains("pub const ECHO_SERVICE_SAY_SPEC"), "{say}");
4955 assert!(say.contains(r#""/pkg.EchoService/Say""#), "{say}");
4956 assert!(say.contains("StreamType::Unary"), "{say}");
4957 assert!(say.contains("IdempotencyLevel::NoSideEffects"), "{say}");
4958 assert!(!say.contains("CLIENT_SPEC"), "{say}");
4960 assert!(!say.contains("Spec::client("), "{say}");
4961
4962 let subscribe = render(&consts[1]);
4963 assert!(
4964 subscribe.contains("StreamType::ServerStream"),
4965 "{subscribe}"
4966 );
4967 assert!(
4968 subscribe.contains("IdempotencyLevel::Idempotent"),
4969 "{subscribe}"
4970 );
4971
4972 let upload = render(&consts[2]);
4973 assert!(upload.contains("StreamType::ClientStream"), "{upload}");
4974 assert!(upload.contains("IdempotencyLevel::Unknown"), "{upload}");
4975
4976 let chat = render(&consts[3]);
4977 assert!(chat.contains("StreamType::BidiStream"), "{chat}");
4978 }
4979
4980 #[test]
4984 fn spec_const_names_do_not_collide_with_method_names() {
4985 let m = |name: &str| MethodDescriptorProto {
4986 name: Some(name.into()),
4987 input_type: Some(".pkg.Req".into()),
4988 output_type: Some(".pkg.Resp".into()),
4989 ..Default::default()
4990 };
4991 let service = ServiceDescriptorProto {
4992 name: Some("X".into()),
4993 method: vec![m("Get"), m("GetSpec"), m("Put"), m("Client"), m("Spec")],
4994 ..Default::default()
4995 };
4996 check_method_collisions("X", &service).unwrap();
4997 }
4998
4999 #[test]
5003 fn client_methods_pass_spec_const_as_client() {
5004 let out = format_minimal_service(false);
5005 assert!(
5006 !out.contains("CLIENT_SPEC"),
5007 "no client sibling const: {out}"
5008 );
5009 let call = out
5011 .find("::connectrpc::client::call_unary(")
5012 .expect("minimal service has a unary client method");
5013 let window = &out[call..(call + 240).min(out.len())];
5014 assert!(
5015 window.contains("PING_SERVICE_PING_SPEC.with_origin(::connectrpc::SpecOrigin::Client)"),
5016 "call_unary must receive the Spec const with client origin:\n{window}"
5017 );
5018 assert!(
5019 !window.contains("PING_SERVICE_SERVICE_NAME"),
5020 "the (service, method) string pair is gone from client call sites:\n{window}"
5021 );
5022 }
5023
5024 #[test]
5025 fn declares_edition_2024_support() {
5026 let response = generate(&CodeGeneratorRequest::default())
5030 .expect("an empty request still yields a response carrying the edition range");
5031 assert_eq!(response.minimum_edition, Some(Edition::EDITION_2023 as i32));
5032 assert_eq!(response.maximum_edition, Some(Edition::EDITION_2024 as i32));
5033 }
5034}