use super::ProtobufGenerator;
use super::base::{escape_string, map_proto_type_to_language, sanitize_identifier, to_pascal_case};
use crate::codegen::protobuf::spec_parser::{FieldLabel, MessageDef, ProtobufSchema, ServiceDef};
use anyhow::Result;
#[derive(Default, Debug, Clone, Copy)]
#[allow(dead_code)]
pub struct RubyProtobufGenerator;
impl ProtobufGenerator for RubyProtobufGenerator {
fn generate_complete(&self, schema: &ProtobufSchema) -> Result<String> {
let mut code = String::new();
code.push_str(&self.file_header());
self.write_package_modules(&mut code, schema);
for message in schema.messages.values() {
code.push_str(&self.generate_message_class(message, self.module_depth(schema)));
code.push('\n');
}
for enum_def in schema.enums.values() {
code.push_str(&self.generate_enum_class(enum_def, self.module_depth(schema)));
code.push('\n');
}
if schema.services.is_empty() {
code.push_str("# No services defined in this schema.\n");
} else {
for service in schema.services.values() {
code.push_str(&self.generate_service_class(service, self.module_depth(schema)));
code.push('\n');
}
}
self.trim_trailing_blank_lines(&mut code);
self.close_package_modules(&mut code, schema);
Ok(code.trim_end().to_string() + "\n")
}
fn generate_messages(&self, schema: &ProtobufSchema) -> Result<String> {
let mut code = String::new();
code.push_str(&self.file_header());
self.write_package_modules(&mut code, schema);
for message in schema.messages.values() {
code.push_str(&self.generate_message_class(message, self.module_depth(schema)));
code.push('\n');
}
for enum_def in schema.enums.values() {
code.push_str(&self.generate_enum_class(enum_def, self.module_depth(schema)));
code.push('\n');
}
self.trim_trailing_blank_lines(&mut code);
self.close_package_modules(&mut code, schema);
Ok(code.trim_end().to_string() + "\n")
}
fn generate_services(&self, schema: &ProtobufSchema) -> Result<String> {
let mut code = String::new();
code.push_str(&self.file_header());
self.write_package_modules(&mut code, schema);
if schema.services.is_empty() {
code.push_str("# No services defined in this schema.\n");
} else {
for service in schema.services.values() {
code.push_str(&self.generate_service_class(service, self.module_depth(schema)));
code.push('\n');
}
}
self.trim_trailing_blank_lines(&mut code);
self.close_package_modules(&mut code, schema);
Ok(code.trim_end().to_string() + "\n")
}
}
impl RubyProtobufGenerator {
fn file_header(&self) -> String {
let mut code = String::new();
code.push_str("# frozen_string_literal: true\n\n");
code.push_str("# DO NOT EDIT - Auto-generated by Spikard CLI\n");
code.push_str("#\n");
code.push_str("# This file was automatically generated from your Protobuf schema.\n");
code.push_str("# Any manual changes will be overwritten on the next generation.\n\n");
code.push_str("require 'google/protobuf'\n\n");
code
}
fn module_depth(&self, schema: &ProtobufSchema) -> usize {
schema
.package
.as_deref()
.map_or(0, |package| package.split('.').count())
}
fn write_package_modules(&self, code: &mut String, schema: &ProtobufSchema) {
if let Some(package) = &schema.package {
code.push_str(&format!("# Package: {package}\n\n"));
for (i, part) in package.split('.').enumerate() {
code.push_str(&format!("{}module {}\n", " ".repeat(i), to_pascal_case(part)));
}
}
}
fn close_package_modules(&self, code: &mut String, schema: &ProtobufSchema) {
if let Some(package) = &schema.package {
for i in (0..package.split('.').count()).rev() {
code.push_str(&format!("{}end\n", " ".repeat(i)));
}
}
}
fn trim_trailing_blank_lines(&self, code: &mut String) {
while code.ends_with("\n\n") {
code.pop();
}
}
#[allow(dead_code)]
fn generate_message_class(&self, message: &MessageDef, module_depth: usize) -> String {
let indent = " ".repeat(module_depth);
let mut code = String::new();
code.push_str(&format!(
"{}# {}\n",
indent,
message.description.as_deref().map_or_else(
|| format!("Protocol Buffer message {}.", message.name),
ToString::to_string
)
));
code.push_str(&format!("{}class {}\n", indent, message.name));
code.push_str(&format!("{indent} include Google::Protobuf::MessageExts\n"));
code.push_str(&format!(
"{indent} extend Google::Protobuf::MessageExts::ClassMethods\n"
));
if message.fields.is_empty() {
code.push_str(&format!("{indent}end\n"));
} else {
for field in &message.fields {
if let Some(desc) = &field.description {
code.push_str(&format!("{indent}\n{indent} # {}\n", escape_string(desc, "ruby")));
}
let field_name = sanitize_identifier(&field.name, "ruby");
let is_optional = field.label == FieldLabel::Optional;
let is_repeated = field.label == FieldLabel::Repeated;
let field_type = map_proto_type_to_language(&field.field_type, "ruby", is_optional, is_repeated);
code.push_str(&format!(
"{indent} # @!attribute [rw] {field_name}\n{indent} # @return [{field_type}]\n"
));
}
code.push_str(&format!("{indent}end\n"));
}
code
}
#[allow(dead_code)]
fn generate_enum_class(
&self,
enum_def: &crate::codegen::protobuf::spec_parser::EnumDef,
module_depth: usize,
) -> String {
let indent = " ".repeat(module_depth);
let mut code = String::new();
code.push_str(&format!(
"{}# {}\n",
indent,
enum_def.description.as_deref().map_or_else(
|| format!("Protocol Buffer enum {}.", enum_def.name),
ToString::to_string
)
));
code.push_str(&format!("{}module {}\n", indent, enum_def.name));
if enum_def.values.is_empty() {
code.push_str(&format!("{indent}end\n"));
} else {
for value in &enum_def.values {
if let Some(desc) = &value.description {
code.push_str(&format!("{indent} # {}\n", escape_string(desc, "ruby")));
}
code.push_str(&format!("{} {} = {}\n", indent, value.name, value.number));
}
code.push_str(&format!("{indent}end\n"));
}
code
}
#[allow(dead_code)]
fn generate_service_class(&self, service: &ServiceDef, module_depth: usize) -> String {
let indent = " ".repeat(module_depth);
let mut code = String::new();
code.push_str(&format!(
"{}# {}\n",
indent,
service.description.as_deref().map_or_else(
|| format!("Server handler interface for {}.", service.name),
|desc| format!(
"Server handler interface for {}. {}",
service.name,
escape_string(desc, "ruby")
)
)
));
code.push_str(&format!("{}class {}\n", indent, service.name));
if service.methods.is_empty() {
code.push_str(&format!("{indent}end\n"));
} else {
for (index, method) in service.methods.iter().enumerate() {
if index > 0 {
code.push('\n');
}
if let Some(desc) = &method.description {
code.push_str(&format!("{indent} # {}\n", escape_string(desc, "ruby")));
}
let method_name = sanitize_identifier(&method.name, "ruby");
let request_type = &method.input_type;
let response_type = &method.output_type;
if method.output_streaming {
code.push_str(&format!(
"{indent} # @param request [{request_type}]\n{indent} # @return [Enumerator<{response_type}>]\n"
));
} else {
code.push_str(&format!(
"{indent} # @param request [{request_type}]\n{indent} # @return [{response_type}]\n"
));
}
code.push_str(&format!(
"{indent} def {method_name}(request)\n{indent} raise NotImplementedError, 'Implement {method_name}'\n{indent} end\n"
));
}
code.push_str(&format!("{indent}end\n"));
}
code
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codegen::protobuf::spec_parser::{FieldDef, FieldLabel, MessageDef, ProtoType};
#[test]
fn test_generate_simple_message() {
let message = MessageDef {
name: "User".to_string(),
fields: vec![
FieldDef {
name: "id".to_string(),
number: 1,
field_type: ProtoType::String,
label: FieldLabel::None,
default_value: None,
description: None,
},
FieldDef {
name: "name".to_string(),
number: 2,
field_type: ProtoType::String,
label: FieldLabel::None,
default_value: None,
description: Some("User's full name".to_string()),
},
],
nested_messages: std::collections::HashMap::new(),
nested_enums: std::collections::HashMap::new(),
description: Some("Represents a user".to_string()),
};
let generator = RubyProtobufGenerator;
let code = generator.generate_message_class(&message, 0);
assert!(code.contains("class User"));
assert!(code.contains("include Google::Protobuf::MessageExts"));
assert!(code.contains("Represents a user"));
assert!(code.contains("@!attribute [rw] id"));
assert!(code.contains("@return [String]"));
}
#[test]
fn test_generate_message_with_optional_field() {
let message = MessageDef {
name: "User".to_string(),
fields: vec![FieldDef {
name: "email".to_string(),
number: 3,
field_type: ProtoType::String,
label: FieldLabel::Optional,
default_value: None,
description: None,
}],
nested_messages: std::collections::HashMap::new(),
nested_enums: std::collections::HashMap::new(),
description: None,
};
let generator = RubyProtobufGenerator;
let code = generator.generate_message_class(&message, 0);
assert!(code.contains("@return [String?]"));
}
#[test]
fn test_generate_message_with_repeated_field() {
let message = MessageDef {
name: "User".to_string(),
fields: vec![FieldDef {
name: "tags".to_string(),
number: 4,
field_type: ProtoType::String,
label: FieldLabel::Repeated,
default_value: None,
description: None,
}],
nested_messages: std::collections::HashMap::new(),
nested_enums: std::collections::HashMap::new(),
description: None,
};
let generator = RubyProtobufGenerator;
let code = generator.generate_message_class(&message, 0);
assert!(code.contains("@return [Array<String>]"));
}
#[test]
fn test_generate_messages_with_package() {
let mut schema = ProtobufSchema {
package: Some("example.user".to_string()),
messages: std::collections::HashMap::new(),
services: std::collections::HashMap::new(),
enums: std::collections::HashMap::new(),
imports: vec![],
syntax: "proto3".to_string(),
description: None,
};
schema.messages.insert(
"User".to_string(),
MessageDef {
name: "User".to_string(),
fields: vec![FieldDef {
name: "id".to_string(),
number: 1,
field_type: ProtoType::String,
label: FieldLabel::None,
default_value: None,
description: None,
}],
nested_messages: std::collections::HashMap::new(),
nested_enums: std::collections::HashMap::new(),
description: None,
},
);
let generator = RubyProtobufGenerator;
let code = generator
.generate_messages(&schema)
.expect("Failed to generate messages");
assert!(code.contains("module Example"));
assert!(code.contains("module User"));
assert!(code.contains("class User"));
assert!(code.contains("Package: example.user"));
}
#[test]
fn test_generate_service_class() {
use crate::codegen::protobuf::spec_parser::{MethodDef, ServiceDef};
let service = ServiceDef {
name: "UserService".to_string(),
methods: vec![MethodDef {
name: "get_user".to_string(),
input_type: "GetUserRequest".to_string(),
output_type: "User".to_string(),
input_streaming: false,
output_streaming: false,
description: None,
}],
description: None,
};
let generator = RubyProtobufGenerator;
let code = generator.generate_service_class(&service, 0);
assert!(code.contains("class UserService"));
assert!(code.contains("def get_user(request)"));
assert!(code.contains("@param request [GetUserRequest]"));
assert!(code.contains("@return [User]"));
assert!(code.contains("raise NotImplementedError"));
}
#[test]
fn test_to_pascal_case_integration() {
use crate::codegen::protobuf::generators::base::to_pascal_case;
assert_eq!(to_pascal_case("example"), "Example");
assert_eq!(to_pascal_case("user_service"), "UserService");
assert_eq!(to_pascal_case("api_v1"), "ApiV1");
}
#[test]
fn test_generate_enum_class() {
use crate::codegen::protobuf::spec_parser::{EnumDef, EnumValue};
let enum_def = EnumDef {
name: "Status".to_string(),
values: vec![
EnumValue {
name: "UNKNOWN".to_string(),
number: 0,
description: None,
},
EnumValue {
name: "ACTIVE".to_string(),
number: 1,
description: Some("User is active".to_string()),
},
],
description: Some("User status enum".to_string()),
};
let generator = RubyProtobufGenerator;
let code = generator.generate_enum_class(&enum_def, 0);
assert!(code.contains("module Status"));
assert!(code.contains("User status enum"));
assert!(code.contains("UNKNOWN = 0"));
assert!(code.contains("ACTIVE = 1"));
assert!(code.contains("User is active"));
}
#[test]
fn test_generate_service_with_streaming() {
use crate::codegen::protobuf::spec_parser::{MethodDef, ServiceDef};
let service = ServiceDef {
name: "UserService".to_string(),
methods: vec![MethodDef {
name: "stream_users".to_string(),
input_type: "StreamRequest".to_string(),
output_type: "User".to_string(),
input_streaming: false,
output_streaming: true,
description: Some("Stream all users".to_string()),
}],
description: None,
};
let generator = RubyProtobufGenerator;
let code = generator.generate_service_class(&service, 0);
assert!(code.contains("def stream_users(request)"));
assert!(code.contains("@return [Enumerator<User>]"));
assert!(code.contains("Stream all users"));
}
}