unibuf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_value"
4
+
5
+ module Unibuf
6
+ module Models
7
+ module Values
8
+ # Represents a map/dictionary value (key-value pairs)
9
+ # Provides hash-like interface with type safety
10
+ class MapValue < BaseValue
11
+ def map?
12
+ true
13
+ end
14
+
15
+ # Hash-like interface
16
+ def key
17
+ raw_value["key"]
18
+ end
19
+
20
+ def value
21
+ raw_value["value"]
22
+ end
23
+
24
+ def to_h
25
+ { key => value }
26
+ end
27
+
28
+ # Type checking
29
+ def key_type
30
+ key.class
31
+ end
32
+
33
+ def value_type
34
+ value.class
35
+ end
36
+
37
+ def scalar_value?
38
+ !value.is_a?(Hash) && !value.is_a?(Array)
39
+ end
40
+
41
+ def message_value?
42
+ value.is_a?(Hash) && value.key?("fields")
43
+ end
44
+
45
+ # Serialization
46
+ def to_textproto(indent: 0)
47
+ indent_str = " " * indent
48
+ key_str = format_value(key)
49
+ val_str = format_value(value)
50
+
51
+ "{\n" \
52
+ "#{indent_str} key: #{key_str}\n" \
53
+ "#{indent_str} value: #{val_str}\n" \
54
+ "#{indent_str}}"
55
+ end
56
+
57
+ # Validation
58
+ def validate!
59
+ unless raw_value.is_a?(Hash)
60
+ raise InvalidValueError,
61
+ "MapValue requires hash, got #{raw_value.class}"
62
+ end
63
+ unless raw_value.key?("key") && raw_value.key?("value")
64
+ raise InvalidValueError, "MapValue requires 'key' and 'value' keys"
65
+ end
66
+
67
+ true
68
+ end
69
+
70
+ # Comparison
71
+ def ==(other)
72
+ return false unless other.is_a?(MapValue)
73
+
74
+ key == other.key && value == other.value
75
+ end
76
+
77
+ def hash
78
+ [self.class, key, value].hash
79
+ end
80
+
81
+ private
82
+
83
+ def format_value(val)
84
+ case val
85
+ when String
86
+ "\"#{val.gsub('\\', '\\\\').gsub('"', '\"')}\""
87
+ when Numeric, TrueClass, FalseClass
88
+ val.to_s
89
+ when Hash
90
+ if val.key?("fields")
91
+ msg = Models::Message.new(val)
92
+ "{\n#{msg.to_textproto(indent: 1)}\n}"
93
+ else
94
+ val.to_s
95
+ end
96
+ else
97
+ val.to_s
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_value"
4
+
5
+ module Unibuf
6
+ module Models
7
+ module Values
8
+ # Represents a nested message value
9
+ # Delegates to Message model for message-specific behavior
10
+ class MessageValue < BaseValue
11
+ attr_reader :message
12
+
13
+ def initialize(raw_value)
14
+ super
15
+ @message = Message.new(raw_value)
16
+ end
17
+
18
+ def message?
19
+ true
20
+ end
21
+
22
+ # Delegation to message
23
+ def fields
24
+ message.fields
25
+ end
26
+
27
+ def field_count
28
+ message.field_count
29
+ end
30
+
31
+ def find_field(name)
32
+ message.find_field(name)
33
+ end
34
+
35
+ def field_names
36
+ message.field_names
37
+ end
38
+
39
+ # Serialization - delegates to message
40
+ def to_textproto(indent: 0)
41
+ indent_str = " " * indent
42
+ nested_content = message.to_textproto(indent: indent + 1)
43
+ "{\n#{nested_content}\n#{indent_str}}"
44
+ end
45
+
46
+ # Validation
47
+ def validate!
48
+ unless raw_value.is_a?(Hash) && raw_value.key?("fields")
49
+ raise InvalidValueError,
50
+ "MessageValue requires hash with 'fields' key"
51
+ end
52
+
53
+ message.validate! if message.respond_to?(:validate!)
54
+ true
55
+ end
56
+
57
+ # Deep equality
58
+ def ==(other)
59
+ return false unless other.is_a?(MessageValue)
60
+
61
+ message == other.message
62
+ end
63
+
64
+ def hash
65
+ [self.class, message].hash
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_value"
4
+
5
+ module Unibuf
6
+ module Models
7
+ module Values
8
+ # Scalar value types: string, integer, float, boolean
9
+ # Immutable value object with type coercion
10
+ class ScalarValue < BaseValue
11
+ def scalar?
12
+ true
13
+ end
14
+
15
+ # Type queries - MECE
16
+ def string?
17
+ raw_value.is_a?(String)
18
+ end
19
+
20
+ def integer?
21
+ raw_value.is_a?(Integer)
22
+ end
23
+
24
+ def float?
25
+ raw_value.is_a?(Float)
26
+ end
27
+
28
+ def boolean?
29
+ raw_value.is_a?(TrueClass) || raw_value.is_a?(FalseClass)
30
+ end
31
+
32
+ def nil?
33
+ raw_value.nil?
34
+ end
35
+
36
+ # Type coercion with validation
37
+ def as_string
38
+ raw_value.to_s
39
+ end
40
+
41
+ def as_integer
42
+ return raw_value if integer?
43
+ return raw_value.to_i if string? && raw_value.match?(/^-?\d+$/)
44
+
45
+ raise TypeCoercionError,
46
+ "Cannot convert #{raw_value.class} to Integer"
47
+ end
48
+
49
+ def as_float
50
+ return raw_value if float?
51
+ return raw_value.to_f if integer?
52
+ return raw_value.to_f if string? && raw_value.match?(/^-?\d+\.?\d*$/)
53
+
54
+ raise TypeCoercionError, "Cannot convert #{raw_value.class} to Float"
55
+ end
56
+
57
+ def as_boolean
58
+ return raw_value if boolean?
59
+ return true if string? && %w[true t 1].include?(raw_value.downcase)
60
+ return false if string? && %w[false f 0].include?(raw_value.downcase)
61
+ return true if raw_value == 1
62
+ return false if raw_value.zero?
63
+
64
+ raise TypeCoercionError,
65
+ "Cannot convert #{raw_value.class} to Boolean"
66
+ end
67
+
68
+ # Serialization
69
+ def to_textproto(indent: 0)
70
+ format_scalar(raw_value)
71
+ end
72
+
73
+ # Validation
74
+ def validate!
75
+ unless [String, Integer, Float, TrueClass, FalseClass,
76
+ NilClass].any? do |t|
77
+ raw_value.is_a?(t)
78
+ end
79
+ raise InvalidValueError, "Invalid scalar type: #{raw_value.class}"
80
+ end
81
+
82
+ true
83
+ end
84
+
85
+ private
86
+
87
+ def format_scalar(value)
88
+ case value
89
+ when String
90
+ escape_string(value)
91
+ when Integer, Float
92
+ value.to_s
93
+ when TrueClass, FalseClass
94
+ value.to_s
95
+ when NilClass
96
+ '""'
97
+ else
98
+ value.to_s
99
+ end
100
+ end
101
+
102
+ def escape_string(str)
103
+ escaped = str.gsub("\\", "\\\\")
104
+ .gsub('"', '\"')
105
+ .gsub("\n", '\\n')
106
+ .gsub("\t", '\\t')
107
+ .gsub("\r", '\\r')
108
+ "\"#{escaped}\""
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ module Parsers
5
+ module Binary
6
+ # Binary Protocol Buffer wire format parser
7
+ # Requires bindata gem for implementation
8
+ #
9
+ # TODO: Implement wire format parsing using bindata
10
+ # Reference: https://protobuf.dev/programming-guides/encoding/
11
+ class WireFormatParser
12
+ attr_reader :schema
13
+
14
+ def initialize(schema)
15
+ @schema = schema
16
+ end
17
+
18
+ def parse(binary_data)
19
+ raise NotImplementedError, <<~MSG
20
+ Binary Protocol Buffer parsing not yet implemented.
21
+
22
+ This feature requires:
23
+ 1. bindata gem integration
24
+ 2. Wire format decoder
25
+ 3. Schema-driven field extraction
26
+ 4. Type deserialization
27
+
28
+ Current implementation: Text format only
29
+ Roadmap: Binary support in v2.0.0
30
+
31
+ For now, use text format:
32
+ Unibuf.parse_textproto(text_content)
33
+ Unibuf.parse_textproto_file("file.txtpb")
34
+ MSG
35
+ end
36
+
37
+ def parse_file(path)
38
+ parse(File.binread(path))
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module Unibuf
6
+ module Parsers
7
+ module Proto3
8
+ # Parslet grammar for parsing Proto3 schema definitions
9
+ # Reference: https://protobuf.dev/reference/protobuf/proto3-spec/
10
+ class Grammar < Parslet::Parser
11
+ # ===== Lexical Elements =====
12
+
13
+ # Whitespace and comments
14
+ rule(:space) { match['\s'].repeat(1) }
15
+ rule(:space?) { space.maybe }
16
+ rule(:newline) { str("\n") }
17
+
18
+ # Comments (// and /* */)
19
+ rule(:line_comment) do
20
+ str("//") >> (newline.absent? >> any).repeat >> newline.maybe
21
+ end
22
+ rule(:block_comment) do
23
+ str("/*") >> (str("*/").absent? >> any).repeat >> str("*/")
24
+ end
25
+ rule(:comment) { line_comment | block_comment }
26
+
27
+ rule(:whitespace) { (space | comment).repeat(1) }
28
+ rule(:whitespace?) { (space | comment).repeat }
29
+
30
+ # Identifiers
31
+ rule(:letter) { match["a-zA-Z_"] }
32
+ rule(:digit) { match["0-9"] }
33
+ rule(:identifier) do
34
+ (letter >> (letter | digit).repeat).as(:identifier)
35
+ end
36
+
37
+ # Strings
38
+ rule(:string_content) { (str('"').absent? >> any).repeat }
39
+ rule(:string_literal) do
40
+ str('"') >> string_content.as(:string) >> str('"')
41
+ end
42
+
43
+ # Numbers
44
+ rule(:number) { (match["+-"].maybe >> digit.repeat(1)).as(:number) }
45
+
46
+ # ===== Syntax Elements =====
47
+
48
+ # Syntax declaration: syntax = "proto3";
49
+ rule(:syntax_stmt) do
50
+ str("syntax") >> whitespace? >> str("=") >> whitespace? >>
51
+ string_literal.as(:syntax_version) >> whitespace? >> str(";")
52
+ end
53
+
54
+ # Package declaration: package google.fonts;
55
+ rule(:package_stmt) do
56
+ str("package") >> whitespace? >>
57
+ identifier.as(:package) >>
58
+ (str(".") >> identifier).repeat >> whitespace? >> str(";")
59
+ end
60
+
61
+ # Import statement: import "other.proto";
62
+ rule(:import_stmt) do
63
+ str("import") >> whitespace? >>
64
+ string_literal.as(:import) >> whitespace? >> str(";")
65
+ end
66
+
67
+ # Field types
68
+ rule(:scalar_type) do
69
+ (str("double") | str("float") | str("int32") | str("int64") |
70
+ str("uint32") | str("uint64") | str("sint32") | str("sint64") |
71
+ str("fixed32") | str("fixed64") | str("sfixed32") | str("sfixed64") |
72
+ str("bool") | str("string") | str("bytes")).as(:scalar_type)
73
+ end
74
+
75
+ rule(:message_type) { identifier.as(:message_type) }
76
+ rule(:field_type) { scalar_type | message_type }
77
+
78
+ # Field definition: string name = 1;
79
+ rule(:field_def) do
80
+ (str("repeated") >> whitespace).maybe.as(:repeated) >>
81
+ field_type.as(:type) >> whitespace >>
82
+ identifier.as(:name) >> whitespace? >>
83
+ str("=") >> whitespace? >>
84
+ number.as(:field_number) >> whitespace? >>
85
+ str(";")
86
+ end
87
+
88
+ # Map field: map<string, int32> mapping = 1;
89
+ rule(:map_field) do
90
+ str("map") >> whitespace? >> str("<") >> whitespace? >>
91
+ field_type.as(:key_type) >> whitespace? >>
92
+ str(",") >> whitespace? >>
93
+ field_type.as(:value_type) >> whitespace? >>
94
+ str(">") >> whitespace >>
95
+ identifier.as(:name) >> whitespace? >>
96
+ str("=") >> whitespace? >>
97
+ number.as(:field_number) >> whitespace? >>
98
+ str(";")
99
+ end
100
+
101
+ # Message definition
102
+ rule(:message_body) do
103
+ (field_def.as(:field) | map_field.as(:map_field) | message_def.as(:nested_message) | whitespace).repeat
104
+ end
105
+
106
+ rule(:message_def) do
107
+ str("message") >> whitespace >>
108
+ identifier.as(:message_name) >> whitespace? >>
109
+ str("{") >> whitespace? >>
110
+ message_body.as(:body) >> whitespace? >>
111
+ str("}")
112
+ end
113
+
114
+ # Enum definition
115
+ rule(:enum_value) do
116
+ identifier.as(:name) >> whitespace? >>
117
+ str("=") >> whitespace? >>
118
+ number.as(:value) >> whitespace? >>
119
+ str(";")
120
+ end
121
+
122
+ rule(:enum_def) do
123
+ str("enum") >> whitespace >>
124
+ identifier.as(:enum_name) >> whitespace? >>
125
+ str("{") >> whitespace? >>
126
+ enum_value.repeat.as(:values) >> whitespace? >>
127
+ str("}")
128
+ end
129
+
130
+ # Top-level elements
131
+ rule(:proto_element) do
132
+ syntax_stmt.as(:syntax) |
133
+ package_stmt.as(:package) |
134
+ import_stmt.as(:import) |
135
+ message_def.as(:message) |
136
+ enum_def.as(:enum) |
137
+ whitespace
138
+ end
139
+
140
+ # Proto file
141
+ rule(:proto_file) do
142
+ whitespace? >> proto_element.repeat >> whitespace?
143
+ end
144
+
145
+ root(:proto_file)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../models/schema"
4
+ require_relative "../../models/message_definition"
5
+ require_relative "../../models/field_definition"
6
+ require_relative "../../models/enum_definition"
7
+
8
+ module Unibuf
9
+ module Parsers
10
+ module Proto3
11
+ # Processor to transform Proto3 AST to Schema models
12
+ class Processor
13
+ class << self
14
+ def process(ast)
15
+ return Models::Schema.new unless ast
16
+
17
+ elements = Array(ast)
18
+
19
+ attributes = {
20
+ syntax: extract_syntax(elements),
21
+ package: extract_package(elements),
22
+ imports: extract_imports(elements),
23
+ messages: extract_messages(elements),
24
+ enums: extract_enums(elements),
25
+ }
26
+
27
+ Models::Schema.new(attributes)
28
+ end
29
+
30
+ private
31
+
32
+ def extract_syntax(elements)
33
+ syntax_element = elements.find { |el| el.key?(:syntax) }
34
+ return "proto3" unless syntax_element
35
+
36
+ syntax_element[:syntax][:syntax_version][:string].to_s
37
+ end
38
+
39
+ def extract_package(elements)
40
+ pkg_element = elements.find { |el| el.key?(:package) }
41
+ return nil unless pkg_element
42
+
43
+ # Package is array of identifiers: [{:package=>{:identifier=>"google"}}, {:identifier=>"fonts"}]
44
+ pkg_parts = Array(pkg_element[:package])
45
+ names = pkg_parts.filter_map do |part|
46
+ if part[:package]
47
+ part[:package][:identifier].to_s
48
+ elsif part[:identifier]
49
+ part[:identifier].to_s
50
+ end
51
+ end
52
+
53
+ names.join(".")
54
+ end
55
+
56
+ def extract_imports(elements)
57
+ elements.select { |el| el.key?(:import) }.map do |el|
58
+ el[:import][:import][:string].to_s
59
+ end
60
+ end
61
+
62
+ def extract_messages(elements)
63
+ elements.select { |el| el.key?(:message) }.map do |el|
64
+ process_message(el[:message])
65
+ end
66
+ end
67
+
68
+ def extract_enums(elements)
69
+ elements.select { |el| el.key?(:enum) }.map do |el|
70
+ process_enum(el[:enum])
71
+ end
72
+ end
73
+
74
+ def process_message(msg_data)
75
+ name = msg_data[:message_name][:identifier].to_s
76
+ body = msg_data[:body]
77
+
78
+ fields = extract_fields(body)
79
+ nested_messages = extract_nested_messages(body)
80
+ nested_enums = extract_nested_enums(body)
81
+
82
+ Models::MessageDefinition.new(
83
+ name: name,
84
+ fields: fields,
85
+ nested_messages: nested_messages,
86
+ nested_enums: nested_enums,
87
+ )
88
+ end
89
+
90
+ def extract_fields(body)
91
+ return [] unless body
92
+
93
+ result = []
94
+
95
+ # Extract regular fields
96
+ Array(body).each do |el|
97
+ if el.respond_to?(:key?) && el.key?(:field)
98
+ result << process_field(el[:field])
99
+ elsif el.respond_to?(:key?) && el.key?(:map_field)
100
+ result << process_map_field(el[:map_field])
101
+ end
102
+ end
103
+
104
+ result
105
+ end
106
+
107
+ def extract_nested_messages(body)
108
+ return [] unless body
109
+
110
+ Array(body).select do |el|
111
+ el.respond_to?(:key?) && el.key?(:nested_message)
112
+ end.map do |el|
113
+ process_message(el[:nested_message])
114
+ end
115
+ end
116
+
117
+ def extract_nested_enums(body)
118
+ return [] unless body
119
+
120
+ Array(body).select do |el|
121
+ el.respond_to?(:key?) && el.key?(:enum)
122
+ end.map do |el|
123
+ process_enum(el[:enum])
124
+ end
125
+ end
126
+
127
+ def process_field(field_data)
128
+ type_info = field_data[:type]
129
+ type = if type_info[:scalar_type]
130
+ type_info[:scalar_type].to_s
131
+ else
132
+ type_info[:message_type][:identifier].to_s
133
+ end
134
+
135
+ Models::FieldDefinition.new(
136
+ name: field_data[:name][:identifier].to_s,
137
+ type: type,
138
+ number: field_data[:field_number][:number].to_s.to_i,
139
+ label: field_data[:repeated] ? "repeated" : nil,
140
+ )
141
+ end
142
+
143
+ def process_map_field(map_data)
144
+ key_type_info = map_data[:key_type]
145
+ key_type = if key_type_info[:scalar_type]
146
+ key_type_info[:scalar_type].to_s
147
+ else
148
+ key_type_info[:message_type][:identifier].to_s
149
+ end
150
+
151
+ value_type_info = map_data[:value_type]
152
+ value_type = if value_type_info[:scalar_type]
153
+ value_type_info[:scalar_type].to_s
154
+ else
155
+ value_type_info[:message_type][:identifier].to_s
156
+ end
157
+
158
+ Models::FieldDefinition.new(
159
+ name: map_data[:name][:identifier].to_s,
160
+ type: "map",
161
+ number: map_data[:field_number][:number].to_s.to_i,
162
+ key_type: key_type,
163
+ value_type: value_type,
164
+ )
165
+ end
166
+
167
+ def process_enum(enum_data)
168
+ name = enum_data[:enum_name][:identifier].to_s
169
+ values = {}
170
+
171
+ Array(enum_data[:values]).each do |val|
172
+ next unless val.respond_to?(:key?) && val.key?(:name)
173
+
174
+ val_name = val[:name][:identifier].to_s
175
+ val_num = val[:value][:number].to_s.to_i
176
+ values[val_name] = val_num
177
+ end
178
+
179
+ Models::EnumDefinition.new(
180
+ name: name,
181
+ values: values,
182
+ )
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end