Skip to content

Commit 5329423

Browse files
Add parser and generator.
1 parent 4e2e6ea commit 5329423

File tree

3 files changed

+271
-0
lines changed

3 files changed

+271
-0
lines changed

lib/protocol/grpc.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
require_relative "grpc/interface"
1717
require_relative "grpc/middleware"
1818
require_relative "grpc/health_check"
19+
require_relative "grpc/proto/parser"
20+
require_relative "grpc/proto/generator"
1921

2022
module Protocol
2123
# Protocol abstractions for gRPC, built on top of `protocol-http`.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "protocol/grpc/interface"
7+
require_relative "parser"
8+
9+
module Protocol
10+
module GRPC
11+
module Proto
12+
# Generator for Protocol Buffers service definitions.
13+
# Generates `Protocol::GRPC::Interface` and `Async::GRPC::Service` classes from parsed proto data.
14+
class Generator
15+
# Initialize the generator with parsed proto data.
16+
# @parameter proto_file [String] Path to the original `.proto` file (for header comments)
17+
# @parameter parsed_data [Hash] Parsed data from `Parser#parse` with `:package` and `:services` keys
18+
def initialize(proto_file, parsed_data)
19+
@proto_file = proto_file
20+
@package = parsed_data[:package]
21+
@services = parsed_data[:services]
22+
end
23+
24+
# Generate the interface class code.
25+
# @parameter service_name [String] The service name
26+
# @parameter output_path [String | Nil] Optional path to write the file
27+
# @returns [String] The generated Ruby code
28+
def generate_interface(service_name, output_path: nil)
29+
service = @services.find{|s| s[:name] == service_name}
30+
raise ArgumentError, "Service #{service_name} not found" unless service
31+
32+
package_module = normalize_package_name(@package)
33+
package_prefix = package_module.empty? ? "" : "#{package_module}::"
34+
35+
code = <<~RUBY
36+
# frozen_string_literal: true
37+
38+
# Generated from #{File.basename(@proto_file)}
39+
# DO NOT EDIT - This file is auto-generated
40+
41+
require "protocol/grpc/interface"
42+
require_relative "#{File.basename(@proto_file, '.proto')}_pb"
43+
44+
#{package_module.empty? ? '' : "module #{package_module}"}
45+
# Interface definition for the #{service_name} service
46+
class #{service_name}Interface < Protocol::GRPC::Interface
47+
#{service[:rpcs].map do |rpc|
48+
streaming_type = case rpc[:streaming]
49+
when :unary then ":unary"
50+
when :server_streaming then ":server_streaming"
51+
when :client_streaming then ":client_streaming"
52+
when :bidirectional then ":bidirectional"
53+
end
54+
55+
"\t\trpc :#{rpc[:name]}, request_class: #{package_prefix}#{rpc[:request]}, response_class: #{package_prefix}#{rpc[:response]}, streaming: #{streaming_type}"
56+
end.join("\n")}
57+
end
58+
#{package_module.empty? ? '' : "end"}
59+
RUBY
60+
61+
if output_path
62+
File.write(output_path, code)
63+
end
64+
65+
code
66+
end
67+
68+
# Generate the service class code with empty implementations.
69+
# @parameter service_name [String] The service name
70+
# @parameter output_path [String | Nil] Optional path to write the file
71+
# @returns [String] The generated Ruby code
72+
def generate_service(service_name, output_path: nil)
73+
service = @services.find{|s| s[:name] == service_name}
74+
raise ArgumentError, "Service #{service_name} not found" unless service
75+
76+
package_module = normalize_package_name(@package)
77+
package_prefix = package_module.empty? ? "" : "#{package_module}::"
78+
interface_class = "#{package_prefix}#{service_name}Interface"
79+
80+
methods = service[:rpcs].map do |rpc|
81+
method_name = pascal_to_snake(rpc[:name])
82+
83+
case rpc[:streaming]
84+
when :unary
85+
<<~RUBY
86+
def #{method_name}(input, output, _call)
87+
request = input.read
88+
# TODO: Implement #{rpc[:name]}
89+
# response = #{package_prefix}#{rpc[:response]}.new(...)
90+
# output.write(response)
91+
end
92+
RUBY
93+
when :server_streaming
94+
<<~RUBY
95+
def #{method_name}(input, output, _call)
96+
request = input.read
97+
# TODO: Implement #{rpc[:name]} streaming
98+
# response = #{package_prefix}#{rpc[:response]}.new(...)
99+
# output.write(response)
100+
end
101+
RUBY
102+
when :client_streaming
103+
<<~RUBY
104+
def #{method_name}(input, output, _call)
105+
# TODO: Implement #{rpc[:name]} client streaming
106+
# input.each do |request|
107+
# # Process request
108+
# end
109+
# response = #{package_prefix}#{rpc[:response]}.new(...)
110+
# output.write(response)
111+
end
112+
RUBY
113+
when :bidirectional
114+
<<~RUBY
115+
def #{method_name}(input, output, _call)
116+
# TODO: Implement #{rpc[:name]} bidirectional streaming
117+
# input.each do |request|
118+
# response = #{package_prefix}#{rpc[:response]}.new(...)
119+
# output.write(response)
120+
# end
121+
end
122+
RUBY
123+
end
124+
end.join("\n")
125+
126+
code = <<~RUBY
127+
# frozen_string_literal: true
128+
129+
# Generated from #{File.basename(@proto_file)}
130+
# DO NOT EDIT - This file is auto-generated
131+
132+
require "async/grpc/service"
133+
require_relative "#{File.basename(@proto_file, '.proto')}_interface"
134+
135+
#{package_module.empty? ? '' : "module #{package_module}"}
136+
# Service implementation for #{service_name}
137+
class #{service_name}Service < Async::GRPC::Service
138+
def initialize(service_name)
139+
super(#{interface_class}, service_name)
140+
end
141+
142+
#{methods.split("\n").map{|line| "\t\t#{line}"}.join("\n")}
143+
end
144+
#{package_module.empty? ? '' : "end"}
145+
RUBY
146+
147+
if output_path
148+
File.write(output_path, code)
149+
end
150+
151+
code
152+
end
153+
154+
private
155+
156+
def normalize_package_name(package)
157+
return "" unless package
158+
159+
package.split(".").map do |part|
160+
# Convert snake_case to PascalCase
161+
part.split("_").map(&:capitalize).join
162+
end.join("::")
163+
end
164+
165+
def pascal_to_snake(pascal)
166+
pascal
167+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
168+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
169+
.downcase
170+
end
171+
end
172+
end
173+
end
174+
end

lib/protocol/grpc/proto/parser.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
module Protocol
7+
module GRPC
8+
module Proto
9+
# Parser for Protocol Buffers `.proto` files.
10+
# Extracts service definitions, RPC methods, and package information.
11+
class Parser
12+
# Initialize the parser with a proto file path.
13+
# @parameter proto_file [String] Path to the `.proto` file
14+
def initialize(proto_file)
15+
@proto_file = proto_file
16+
@content = File.read(proto_file)
17+
end
18+
19+
# Parse the proto file and return structured data.
20+
# @returns [Hash] Parsed data with `:package` and `:services` keys
21+
def parse
22+
package = extract_package
23+
services = extract_services
24+
25+
{
26+
package: package,
27+
services: services
28+
}
29+
end
30+
31+
# Get the package name.
32+
# @returns [String | Nil] The package name
33+
def package
34+
extract_package
35+
end
36+
37+
# Get all service names found in the proto file.
38+
# @returns [Array<String>] List of service names
39+
def service_names
40+
extract_services.map{|s| s[:name]}
41+
end
42+
43+
private
44+
45+
def extract_package
46+
@content[/package\s+([\w.]+)\s*;/, 1]
47+
end
48+
49+
def extract_services
50+
services = []
51+
52+
@content.scan(/service\s+(\w+)\s*\{([^}]+)\}/m) do |service_name, service_body|
53+
rpcs = []
54+
55+
service_body.scan(/rpc\s+(\w+)\s*\(([^)]+)\)\s+returns\s*\(([^)]+)\)\s*;/) do |rpc_name, request, response|
56+
request = request.strip
57+
response = response.strip
58+
59+
# Determine streaming type
60+
request_streaming = request.start_with?("stream ")
61+
response_streaming = response.start_with?("stream ")
62+
63+
request_type = request.sub(/^stream\s+/, "")
64+
response_type = response.sub(/^stream\s+/, "")
65+
66+
streaming = if request_streaming && response_streaming
67+
:bidirectional
68+
elsif response_streaming
69+
:server_streaming
70+
elsif request_streaming
71+
:client_streaming
72+
else
73+
:unary
74+
end
75+
76+
rpcs << {
77+
name: rpc_name,
78+
request: request_type,
79+
response: response_type,
80+
streaming: streaming
81+
}
82+
end
83+
84+
services << {
85+
name: service_name,
86+
rpcs: rpcs
87+
}
88+
end
89+
90+
services
91+
end
92+
end
93+
end
94+
end
95+
end

0 commit comments

Comments
 (0)