unibuf 0.1.0 → 0.1.2

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +178 -330
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/README.adoc +443 -254
  5. data/docs/CAPNPROTO.adoc +436 -0
  6. data/docs/FLATBUFFERS.adoc +430 -0
  7. data/docs/PROTOBUF.adoc +515 -0
  8. data/docs/TXTPROTO.adoc +369 -0
  9. data/lib/unibuf/commands/convert.rb +60 -2
  10. data/lib/unibuf/commands/schema.rb +68 -11
  11. data/lib/unibuf/errors.rb +23 -26
  12. data/lib/unibuf/models/capnproto/enum_definition.rb +72 -0
  13. data/lib/unibuf/models/capnproto/field_definition.rb +81 -0
  14. data/lib/unibuf/models/capnproto/interface_definition.rb +70 -0
  15. data/lib/unibuf/models/capnproto/method_definition.rb +81 -0
  16. data/lib/unibuf/models/capnproto/schema.rb +84 -0
  17. data/lib/unibuf/models/capnproto/struct_definition.rb +96 -0
  18. data/lib/unibuf/models/capnproto/union_definition.rb +62 -0
  19. data/lib/unibuf/models/flatbuffers/enum_definition.rb +69 -0
  20. data/lib/unibuf/models/flatbuffers/field_definition.rb +88 -0
  21. data/lib/unibuf/models/flatbuffers/schema.rb +102 -0
  22. data/lib/unibuf/models/flatbuffers/struct_definition.rb +70 -0
  23. data/lib/unibuf/models/flatbuffers/table_definition.rb +73 -0
  24. data/lib/unibuf/models/flatbuffers/union_definition.rb +60 -0
  25. data/lib/unibuf/models/message.rb +10 -0
  26. data/lib/unibuf/models/values/scalar_value.rb +2 -2
  27. data/lib/unibuf/parsers/binary/wire_format_parser.rb +199 -19
  28. data/lib/unibuf/parsers/capnproto/binary_parser.rb +267 -0
  29. data/lib/unibuf/parsers/capnproto/grammar.rb +272 -0
  30. data/lib/unibuf/parsers/capnproto/list_reader.rb +208 -0
  31. data/lib/unibuf/parsers/capnproto/pointer_decoder.rb +163 -0
  32. data/lib/unibuf/parsers/capnproto/processor.rb +348 -0
  33. data/lib/unibuf/parsers/capnproto/segment_reader.rb +131 -0
  34. data/lib/unibuf/parsers/capnproto/struct_reader.rb +199 -0
  35. data/lib/unibuf/parsers/flatbuffers/binary_parser.rb +325 -0
  36. data/lib/unibuf/parsers/flatbuffers/grammar.rb +235 -0
  37. data/lib/unibuf/parsers/flatbuffers/processor.rb +299 -0
  38. data/lib/unibuf/parsers/textproto/grammar.rb +1 -1
  39. data/lib/unibuf/parsers/textproto/processor.rb +10 -0
  40. data/lib/unibuf/serializers/binary_serializer.rb +218 -0
  41. data/lib/unibuf/serializers/capnproto/binary_serializer.rb +402 -0
  42. data/lib/unibuf/serializers/capnproto/list_writer.rb +199 -0
  43. data/lib/unibuf/serializers/capnproto/pointer_encoder.rb +118 -0
  44. data/lib/unibuf/serializers/capnproto/segment_builder.rb +124 -0
  45. data/lib/unibuf/serializers/capnproto/struct_writer.rb +139 -0
  46. data/lib/unibuf/serializers/flatbuffers/binary_serializer.rb +167 -0
  47. data/lib/unibuf/validators/type_validator.rb +1 -1
  48. data/lib/unibuf/version.rb +1 -1
  49. data/lib/unibuf.rb +27 -0
  50. metadata +36 -1
@@ -0,0 +1,369 @@
1
+ = Protocol Buffers Text Format (Textproto) Support
2
+
3
+ :toc:
4
+ :toclevels: 3
5
+
6
+ == Purpose
7
+
8
+ Unibuf provides complete support for Protocol Buffers text format (textproto), a human-readable representation of protobuf messages.
9
+
10
+ Features:
11
+
12
+ * Parse `.txtpb` and `.textproto` files
13
+ * No schema required for parsing (schema recommended for validation)
14
+ * Support all Protocol Buffers field types
15
+ * Nested messages and repeated fields
16
+ * Comments and whitespace handling
17
+ * Round-trip serialization
18
+ * Rich domain models
19
+
20
+ == Text Format Overview
21
+
22
+ Text format (textproto) is:
23
+
24
+ Human-readable::
25
+ Easy to read and edit by humans
26
+
27
+ Self-documenting::
28
+ Field names make structure obvious
29
+
30
+ Configuration-friendly::
31
+ Ideal for configuration files
32
+
33
+ Debug-friendly::
34
+ Easy to inspect message contents
35
+
36
+ == Parsing Text Format
37
+
38
+ === Basic parsing
39
+
40
+ [source,ruby]
41
+ ----
42
+ require "unibuf"
43
+
44
+ # Parse from file
45
+ message = Unibuf.parse_textproto_file("config.txtpb") # <1>
46
+
47
+ # Parse from string
48
+ text = <<~TEXTPROTO
49
+ name: "Alice"
50
+ id: 123
51
+ TEXTPROTO
52
+
53
+ message = Unibuf.parse_textproto(text) # <2>
54
+
55
+ # Access fields
56
+ puts message.find_field("name").value # => "Alice" # <3>
57
+ puts message.find_field("id").value # => 123 # <4>
58
+ ----
59
+ <1> Parse from file
60
+ <2> Parse from string
61
+ <3> Access string field
62
+ <4> Access integer field
63
+
64
+ === Parsing nested messages
65
+
66
+ [source,ruby]
67
+ ----
68
+ text = <<~TEXTPROTO
69
+ name: "Alice"
70
+ address {
71
+ street: "123 Main St"
72
+ city: "Springfield"
73
+ }
74
+ TEXTPROTO
75
+
76
+ message = Unibuf.parse_textproto(text) # <1>
77
+
78
+ # Access nested message
79
+ address = message.find_field("address").value # <2>
80
+ puts address.find_field("street").value # => "123 Main St" # <3>
81
+ ----
82
+ <1> Parse text with nested message
83
+ <2> Get nested message field
84
+ <3> Access field in nested message
85
+
86
+ === Parsing repeated fields
87
+
88
+ [source,ruby]
89
+ ----
90
+ text = <<~TEXTPROTO
91
+ name: "Alice"
92
+ tags: "important"
93
+ tags: "customer"
94
+ tags: "vip"
95
+ TEXTPROTO
96
+
97
+ message = Unibuf.parse_textproto(text) # <1>
98
+
99
+ # Access repeated field
100
+ tags = message.find_fields("tags") # <2>
101
+ tag_values = tags.map(&:value) # => ["important", "customer", "vip"] # <3>
102
+ ----
103
+ <1> Parse text with repeated fields
104
+ <2> Find all fields with name "tags"
105
+ <3> Extract values
106
+
107
+ == Text Format Syntax
108
+
109
+ === Field types
110
+
111
+ String fields::
112
+ +
113
+ [source,textproto]
114
+ ----
115
+ name: "Alice"
116
+ description: "A person"
117
+ ----
118
+
119
+ Integer fields::
120
+ +
121
+ [source,textproto]
122
+ ----
123
+ id: 123
124
+ count: -42
125
+ large: 9223372036854775807
126
+ ----
127
+
128
+ Float fields::
129
+ +
130
+ [source,textproto]
131
+ ----
132
+ price: 19.99
133
+ ratio: 0.5
134
+ scientific: 1.23e-4
135
+ ----
136
+
137
+ Boolean fields::
138
+ +
139
+ [source,textproto]
140
+ ----
141
+ enabled: true
142
+ active: false
143
+ ----
144
+
145
+ === Nested messages
146
+
147
+ [source,textproto]
148
+ ----
149
+ person {
150
+ name: "Alice"
151
+ address {
152
+ street: "123 Main St"
153
+ city: "Springfield"
154
+ }
155
+ }
156
+ ----
157
+
158
+ === Repeated fields
159
+
160
+ [source,textproto]
161
+ ----
162
+ # Multiple values for same field
163
+ tags: "tag1"
164
+ tags: "tag2"
165
+ tags: "tag3"
166
+
167
+ # Or as nested messages
168
+ people {
169
+ name: "Alice"
170
+ }
171
+ people {
172
+ name: "Bob"
173
+ }
174
+ ----
175
+
176
+ === Comments
177
+
178
+ [source,textproto]
179
+ ----
180
+ # Line comment
181
+ name: "Alice" # Inline comment
182
+
183
+ /*
184
+ * Block comment
185
+ */
186
+ id: 123
187
+ ----
188
+
189
+ == Domain Models
190
+
191
+ === Message model
192
+
193
+ [source,ruby]
194
+ ----
195
+ message = Unibuf.parse_textproto_file("data.txtpb")
196
+
197
+ # Classification
198
+ message.nested? # Has nested messages?
199
+ message.scalar_only? # Only scalar values?
200
+ message.repeated_fields? # Has repeated fields?
201
+
202
+ # Queries
203
+ message.find_field("name") # Find single field
204
+ message.find_fields("tags") # Find all with name
205
+ message.field_names # All field names
206
+ message.repeated_field_names # Repeated field names
207
+
208
+ # Traversal
209
+ message.traverse_depth_first { |field| puts field.name }
210
+ message.traverse_breadth_first { |field| puts field.value }
211
+ message.depth # Maximum nesting depth
212
+ ----
213
+
214
+ === Field model
215
+
216
+ [source,ruby]
217
+ ----
218
+ field = message.find_field("address")
219
+
220
+ # Properties
221
+ field.name # Field name
222
+ field.value # Field value
223
+ field.repeated? # Is repeated field?
224
+
225
+ # Type classification
226
+ field.scalar? # Scalar value?
227
+ field.message? # Nested message?
228
+ field.list? # List value?
229
+ field.map? # Map value?
230
+ ----
231
+
232
+ === Value types
233
+
234
+ ScalarValue::
235
+ Strings, integers, floats, booleans
236
+
237
+ MessageValue::
238
+ Nested messages
239
+
240
+ ListValue::
241
+ Repeated fields
242
+
243
+ MapValue::
244
+ Key-value maps
245
+
246
+ == Serialization
247
+
248
+ === Serializing to text format
249
+
250
+ [source,ruby]
251
+ ----
252
+ # Parse message
253
+ message = Unibuf.parse_textproto_file("input.txtpb") # <1>
254
+
255
+ # Modify if needed
256
+ # ... modifications ...
257
+
258
+ # Serialize back to text format
259
+ output = message.to_textproto # <2>
260
+
261
+ # Write to file
262
+ File.write("output.txtpb", output) # <3>
263
+
264
+ # Verify round-trip
265
+ reparsed = Unibuf.parse_textproto(output) # <4>
266
+ puts message == reparsed # => true # <5>
267
+ ----
268
+ <1> Parse original
269
+ <2> Serialize to text
270
+ <3> Write output
271
+ <4> Parse again
272
+ <5> Verify equivalence
273
+
274
+ == With Schema Validation
275
+
276
+ === Parsing with validation
277
+
278
+ [source,ruby]
279
+ ----
280
+ # Load schema
281
+ schema = Unibuf.parse_schema("schema.proto") # <1>
282
+
283
+ # Parse text format
284
+ message = Unibuf.parse_textproto_file("data.txtpb") # <2>
285
+
286
+ # Validate
287
+ validator = Unibuf::Validators::SchemaValidator.new(schema) # <3>
288
+ validator.validate!(message, "Person") # <4>
289
+ ----
290
+ <1> Load schema
291
+ <2> Parse text format
292
+ <3> Create validator
293
+ <4> Validate against schema
294
+
295
+ == Testing
296
+
297
+ === Test coverage
298
+
299
+ Text format implementation includes:
300
+
301
+ Grammar tests (60+ tests)::
302
+ All syntax elements, strings, numbers, booleans, nested messages
303
+
304
+ Parser tests (40+ tests)::
305
+ Field parsing, message building, error handling
306
+
307
+ Integration tests (40+ tests)::
308
+ Real-world Protocol Buffer files (Google Fonts metadata)
309
+
310
+ Serialization tests (20+ tests)::
311
+ Round-trip verification, formatting
312
+
313
+ **Total: 160+ tests, 100% passing**
314
+
315
+ === Running tests
316
+
317
+ [source,shell]
318
+ ----
319
+ # Run textproto tests
320
+ bundle exec rspec spec/unibuf/parsers/textproto/
321
+
322
+ # Run specific test
323
+ bundle exec rspec spec/unibuf/parsers/textproto/grammar_spec.rb
324
+ ----
325
+
326
+ == Real-World Examples
327
+
328
+ === Google Fonts metadata
329
+
330
+ Unibuf successfully parses Google Fonts METADATA.pb files:
331
+
332
+ [source,ruby]
333
+ ----
334
+ # Parse Google Font metadata
335
+ message = Unibuf.parse_file("METADATA.pb")
336
+
337
+ # Access font information
338
+ name = message.find_field("name")&.value
339
+ category = message.find_field("category")&.value
340
+ designers = message.find_fields("designer").map(&:value)
341
+
342
+ puts "Font: #{name}"
343
+ puts "Category: #{category}"
344
+ puts "Designers: #{designers.join(', ')}"
345
+ ----
346
+
347
+ == References
348
+
349
+ Protocol Buffers text format::
350
+ https://protobuf.dev/reference/protobuf/textformat-spec/
351
+
352
+ Proto3 language guide::
353
+ https://protobuf.dev/programming-guides/proto3/
354
+
355
+ Encoding specification::
356
+ https://protobuf.dev/programming-guides/encoding/
357
+
358
+ == Support
359
+
360
+ For issues, questions, or contributions related to Protocol Buffers text format:
361
+
362
+ * GitHub Issues: https://github.com/lutaml/unibuf/issues
363
+ * Documentation: https://github.com/lutaml/unibuf/tree/main/docs
364
+
365
+ == Copyright and License
366
+
367
+ Copyright https://www.ribose.com[Ribose Inc.]
368
+
369
+ Licensed under the 3-clause BSD License.
@@ -42,12 +42,24 @@ module Unibuf
42
42
  "Target format required"
43
43
  end
44
44
 
45
- valid_formats = %w[json yaml textproto]
45
+ valid_formats = %w[json yaml textproto binpb]
46
46
  unless valid_formats.include?(target_format)
47
47
  raise InvalidArgumentError,
48
48
  "Invalid format '#{target_format}'. " \
49
49
  "Valid formats: #{valid_formats.join(', ')}"
50
50
  end
51
+
52
+ # Binary format requires schema
53
+ if target_format == "binpb" && !schema_file
54
+ raise InvalidArgumentError,
55
+ "Binary format requires --schema option"
56
+ end
57
+
58
+ # Binary input requires schema
59
+ if binary_file?(file) && !schema_file
60
+ raise InvalidArgumentError,
61
+ "Binary input requires --schema option"
62
+ end
51
63
  end
52
64
 
53
65
  def load_message(file)
@@ -56,6 +68,8 @@ module Unibuf
56
68
  load_from_json(file)
57
69
  elsif yaml_file?(file)
58
70
  load_from_yaml(file)
71
+ elsif binary_file?(file)
72
+ load_from_binary(file)
59
73
  else
60
74
  Unibuf.parse_file(file)
61
75
  end
@@ -73,6 +87,11 @@ module Unibuf
73
87
  Unibuf::Models::Message.from_hash(data)
74
88
  end
75
89
 
90
+ def load_from_binary(file)
91
+ schema = load_schema
92
+ Unibuf.parse_binary_file(file, schema: schema)
93
+ end
94
+
76
95
  def convert_message(message)
77
96
  case target_format
78
97
  when "json"
@@ -81,18 +100,45 @@ module Unibuf
81
100
  message.to_yaml
82
101
  when "textproto"
83
102
  message.to_textproto
103
+ when "binpb"
104
+ schema = load_schema
105
+ message.to_binary(schema: schema, message_type: message_type)
84
106
  end
85
107
  end
86
108
 
87
109
  def write_output(content)
88
110
  if output_file
89
- File.write(output_file, content)
111
+ if target_format == "binpb"
112
+ File.binwrite(output_file, content)
113
+ else
114
+ File.write(output_file, content)
115
+ end
90
116
  puts "Output written to #{output_file}" if verbose?
117
+ elsif target_format == "binpb"
118
+ $stdout.binmode
119
+ $stdout.write(content)
120
+ # Binary output to stdout
91
121
  else
92
122
  puts content
93
123
  end
94
124
  end
95
125
 
126
+ def load_schema
127
+ return @schema if @schema
128
+
129
+ unless schema_file
130
+ raise InvalidArgumentError,
131
+ "Schema required for binary format"
132
+ end
133
+
134
+ unless File.exist?(schema_file)
135
+ raise FileNotFoundError,
136
+ "Schema file not found: #{schema_file}"
137
+ end
138
+
139
+ @schema = Unibuf.parse_schema(schema_file)
140
+ end
141
+
96
142
  def json_file?(file)
97
143
  file.end_with?(".json")
98
144
  end
@@ -101,6 +147,10 @@ module Unibuf
101
147
  file.end_with?(".yaml", ".yml")
102
148
  end
103
149
 
150
+ def binary_file?(file)
151
+ file.end_with?(".binpb", ".bin", ".pb")
152
+ end
153
+
104
154
  def target_format
105
155
  options[:to]
106
156
  end
@@ -109,6 +159,14 @@ module Unibuf
109
159
  options[:output]
110
160
  end
111
161
 
162
+ def schema_file
163
+ options[:schema]
164
+ end
165
+
166
+ def message_type
167
+ options[:message_type]
168
+ end
169
+
112
170
  def verbose?
113
171
  options[:verbose]
114
172
  end
@@ -43,6 +43,8 @@ module Unibuf
43
43
  Unibuf.parse_schema(file)
44
44
  when ".fbs"
45
45
  Unibuf.parse_flatbuffers_schema(file)
46
+ when ".capnp"
47
+ Unibuf.parse_capnproto_schema(file)
46
48
  else
47
49
  raise InvalidArgumentError,
48
50
  "Unknown schema format: #{File.extname(file)}"
@@ -77,20 +79,75 @@ module Unibuf
77
79
 
78
80
  def format_text(schema)
79
81
  lines = []
80
- lines << "Package: #{schema.package}" if schema.package
81
- lines << "Syntax: #{schema.syntax}"
82
- lines << ""
83
- lines << "Messages (#{schema.messages.size}):"
84
- schema.messages.each do |msg|
85
- lines << " #{msg.name} (#{msg.fields.size} fields)"
86
- end
87
- if schema.enums.any?
82
+
83
+ # Handle different schema types
84
+ if schema.respond_to?(:package)
85
+ # Proto3 schema
86
+ lines << "Package: #{schema.package}" if schema.package
87
+ lines << "Syntax: #{schema.syntax}"
88
88
  lines << ""
89
- lines << "Enums (#{schema.enums.size}):"
90
- schema.enums.each do |enum|
91
- lines << " #{enum.name} (#{enum.values.size} values)"
89
+ lines << "Messages (#{schema.messages.size}):"
90
+ schema.messages.each do |msg|
91
+ lines << " #{msg.name} (#{msg.fields.size} fields)"
92
+ end
93
+ if schema.enums.any?
94
+ lines << ""
95
+ lines << "Enums (#{schema.enums.size}):"
96
+ schema.enums.each do |enum|
97
+ lines << " #{enum.name} (#{enum.values.size} values)"
98
+ end
99
+ end
100
+ elsif schema.respond_to?(:file_id)
101
+ # Cap'n Proto schema
102
+ lines << "File ID: #{schema.file_id}" if schema.file_id
103
+ lines << ""
104
+ if schema.structs.any?
105
+ lines << "Structs (#{schema.structs.size}):"
106
+ schema.structs.each do |struct|
107
+ lines << " #{struct.name} (#{struct.fields.size} fields)"
108
+ end
109
+ end
110
+ if schema.enums.any?
111
+ lines << ""
112
+ lines << "Enums (#{schema.enums.size}):"
113
+ schema.enums.each do |enum|
114
+ lines << " #{enum.name} (#{enum.values.size} values)"
115
+ end
116
+ end
117
+ if schema.interfaces.any?
118
+ lines << ""
119
+ lines << "Interfaces (#{schema.interfaces.size}):"
120
+ schema.interfaces.each do |interface|
121
+ lines << " #{interface.name} (#{interface.methods.size} methods)"
122
+ end
123
+ end
124
+ else
125
+ # FlatBuffers schema
126
+ lines << "Namespace: #{schema.namespace}" if schema.namespace
127
+ lines << "Root Type: #{schema.root_type}" if schema.root_type
128
+ lines << ""
129
+ if schema.tables.any?
130
+ lines << "Tables (#{schema.tables.size}):"
131
+ schema.tables.each do |table|
132
+ lines << " #{table.name} (#{table.fields.size} fields)"
133
+ end
134
+ end
135
+ if schema.structs.any?
136
+ lines << ""
137
+ lines << "Structs (#{schema.structs.size}):"
138
+ schema.structs.each do |struct|
139
+ lines << " #{struct.name} (#{struct.fields.size} fields)"
140
+ end
141
+ end
142
+ if schema.enums.any?
143
+ lines << ""
144
+ lines << "Enums (#{schema.enums.size}):"
145
+ schema.enums.each do |enum|
146
+ lines << " #{enum.name} (#{enum.values.size} values)"
147
+ end
92
148
  end
93
149
  end
150
+
94
151
  lines.join("\n")
95
152
  end
96
153
 
data/lib/unibuf/errors.rb CHANGED
@@ -1,36 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Unibuf
4
- # Base error class for all Unibuf errors
4
+ # Base error class
5
5
  class Error < StandardError; end
6
6
 
7
- # Parsing errors
7
+ # Parse error
8
8
  class ParseError < Error; end
9
- class SyntaxError < ParseError; end
10
- class UnexpectedTokenError < ParseError; end
11
- class UnterminatedStringError < ParseError; end
12
9
 
13
- # Validation errors
10
+ # Serialization error
11
+ class SerializationError < Error; end
12
+
13
+ # Validation error
14
14
  class ValidationError < Error; end
15
- class TypeValidationError < ValidationError; end
15
+
16
+ # Schema validation error
16
17
  class SchemaValidationError < ValidationError; end
17
- class ReferenceValidationError < ValidationError; end
18
- class RequiredFieldError < ValidationError; end
19
-
20
- # Model errors
21
- class ModelError < Error; end
22
- class InvalidFieldError < ModelError; end
23
- class InvalidValueError < ModelError; end
24
- class TypeCoercionError < ModelError; end
25
-
26
- # File errors
27
- class FileError < Error; end
28
- class FileNotFoundError < FileError; end
29
- class FileReadError < FileError; end
30
- class FileWriteError < FileError; end
31
-
32
- # CLI errors
33
- class CLIError < Error; end
34
- class InvalidArgumentError < CLIError; end
35
- class CommandExecutionError < CLIError; end
18
+
19
+ # Type validation error
20
+ class TypeValidationError < ValidationError; end
21
+
22
+ # Invalid value error
23
+ class InvalidValueError < ValidationError; end
24
+
25
+ # Type coercion error
26
+ class TypeCoercionError < Error; end
27
+
28
+ # File not found error
29
+ class FileNotFoundError < Error; end
30
+
31
+ # Invalid argument error
32
+ class InvalidArgumentError < Error; end
36
33
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ module Models
5
+ module Capnproto
6
+ # Represents a Cap'n Proto enum definition
7
+ class EnumDefinition
8
+ attr_reader :name, :values, :annotations
9
+
10
+ def initialize(attributes = {})
11
+ @name = attributes[:name] || attributes["name"]
12
+ @values = attributes[:values] || attributes["values"] || {}
13
+ @annotations = Array(
14
+ attributes[:annotations] || attributes["annotations"],
15
+ )
16
+ end
17
+
18
+ # Queries
19
+ def value_names
20
+ values.keys
21
+ end
22
+
23
+ def ordinals
24
+ values.values
25
+ end
26
+
27
+ def find_value(name)
28
+ values[name]
29
+ end
30
+
31
+ def find_name_by_ordinal(ordinal)
32
+ values.find { |_name, ord| ord == ordinal }&.first
33
+ end
34
+
35
+ # Validation
36
+ def valid?
37
+ validate!
38
+ true
39
+ rescue ValidationError
40
+ false
41
+ end
42
+
43
+ def validate!
44
+ raise ValidationError, "Enum name required" unless name
45
+
46
+ if values.empty?
47
+ raise ValidationError,
48
+ "Enum must have at least one value"
49
+ end
50
+
51
+ # Check for duplicate ordinals
52
+ ordinal_counts = ordinals.tally
53
+ duplicates = ordinal_counts.select { |_ord, count| count > 1 }
54
+ unless duplicates.empty?
55
+ raise ValidationError,
56
+ "Duplicate ordinals in enum '#{name}': #{duplicates.keys.join(', ')}"
57
+ end
58
+
59
+ true
60
+ end
61
+
62
+ def to_h
63
+ {
64
+ name: name,
65
+ values: values,
66
+ annotations: annotations,
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end