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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ module Commands
5
+ # Convert command - Convert between Protocol Buffer formats
6
+ class Convert
7
+ attr_reader :options
8
+
9
+ def initialize(options = {})
10
+ @options = options
11
+ end
12
+
13
+ def run(file)
14
+ validate_inputs!(file)
15
+
16
+ puts "Converting #{file} to #{target_format}..." if verbose?
17
+
18
+ message = load_message(file)
19
+ converted = convert_message(message)
20
+ write_output(converted)
21
+
22
+ puts "✓ Converted successfully" if verbose?
23
+ rescue FileNotFoundError => e
24
+ error "File not found: #{e.message}"
25
+ exit 1
26
+ rescue InvalidArgumentError => e
27
+ error "Invalid argument: #{e.message}"
28
+ exit 1
29
+ rescue StandardError => e
30
+ error "Error: #{e.message}"
31
+ error e.backtrace.first(5).join("\n") if verbose?
32
+ exit 1
33
+ end
34
+
35
+ private
36
+
37
+ def validate_inputs!(file)
38
+ raise FileNotFoundError, file unless File.exist?(file)
39
+
40
+ unless target_format
41
+ raise InvalidArgumentError,
42
+ "Target format required"
43
+ end
44
+
45
+ valid_formats = %w[json yaml textproto]
46
+ unless valid_formats.include?(target_format)
47
+ raise InvalidArgumentError,
48
+ "Invalid format '#{target_format}'. " \
49
+ "Valid formats: #{valid_formats.join(', ')}"
50
+ end
51
+ end
52
+
53
+ def load_message(file)
54
+ # Detect source format and parse appropriately
55
+ if json_file?(file)
56
+ load_from_json(file)
57
+ elsif yaml_file?(file)
58
+ load_from_yaml(file)
59
+ else
60
+ Unibuf.parse_file(file)
61
+ end
62
+ end
63
+
64
+ def load_from_json(file)
65
+ require "json"
66
+ data = JSON.parse(File.read(file))
67
+ Unibuf::Models::Message.from_hash(data)
68
+ end
69
+
70
+ def load_from_yaml(file)
71
+ require "yaml"
72
+ data = YAML.load_file(file)
73
+ Unibuf::Models::Message.from_hash(data)
74
+ end
75
+
76
+ def convert_message(message)
77
+ case target_format
78
+ when "json"
79
+ message.to_json
80
+ when "yaml"
81
+ message.to_yaml
82
+ when "textproto"
83
+ message.to_textproto
84
+ end
85
+ end
86
+
87
+ def write_output(content)
88
+ if output_file
89
+ File.write(output_file, content)
90
+ puts "Output written to #{output_file}" if verbose?
91
+ else
92
+ puts content
93
+ end
94
+ end
95
+
96
+ def json_file?(file)
97
+ file.end_with?(".json")
98
+ end
99
+
100
+ def yaml_file?(file)
101
+ file.end_with?(".yaml", ".yml")
102
+ end
103
+
104
+ def target_format
105
+ options[:to]
106
+ end
107
+
108
+ def output_file
109
+ options[:output]
110
+ end
111
+
112
+ def verbose?
113
+ options[:verbose]
114
+ end
115
+
116
+ def error(message)
117
+ warn "Error: #{message}"
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ module Commands
5
+ # Parse command - Parse Protocol Buffer text format files
6
+ class Parse
7
+ attr_reader :options
8
+
9
+ def initialize(options = {})
10
+ @options = options
11
+ end
12
+
13
+ def run(file)
14
+ validate_file!(file)
15
+
16
+ puts "Parsing #{file}..." if verbose?
17
+
18
+ message = parse_file(file)
19
+ output_result(message)
20
+
21
+ puts "✓ Successfully parsed #{file}" if verbose?
22
+ rescue FileNotFoundError => e
23
+ error "File not found: #{e.message}"
24
+ exit 1
25
+ rescue ParseError => e
26
+ error "Parse error: #{e.message}"
27
+ exit 1
28
+ rescue StandardError => e
29
+ error "Unexpected error: #{e.message}"
30
+ error e.backtrace.first(5).join("\n") if verbose?
31
+ exit 1
32
+ end
33
+
34
+ private
35
+
36
+ def validate_file!(file)
37
+ raise FileNotFoundError, file unless File.exist?(file)
38
+ end
39
+
40
+ def parse_file(file)
41
+ Unibuf.parse_file(file)
42
+ end
43
+
44
+ def output_result(message)
45
+ content = format_output(message)
46
+
47
+ if output_file
48
+ File.write(output_file, content)
49
+ puts "Output written to #{output_file}" if verbose?
50
+ else
51
+ puts content
52
+ end
53
+ end
54
+
55
+ def format_output(message)
56
+ case output_format
57
+ when "json"
58
+ message.to_json
59
+ when "yaml"
60
+ message.to_yaml
61
+ when "textproto"
62
+ message.to_textproto
63
+ else
64
+ raise InvalidArgumentError, "Unknown format: #{output_format}"
65
+ end
66
+ end
67
+
68
+ def output_file
69
+ options[:output]
70
+ end
71
+
72
+ def output_format
73
+ options[:format] || "json"
74
+ end
75
+
76
+ def verbose?
77
+ options[:verbose]
78
+ end
79
+
80
+ def error(message)
81
+ warn "Error: #{message}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ module Commands
5
+ # Schema command - Parse and display schema files
6
+ class Schema
7
+ attr_reader :options
8
+
9
+ def initialize(options = {})
10
+ @options = options
11
+ end
12
+
13
+ def run(file)
14
+ validate_file!(file)
15
+
16
+ puts "Parsing schema #{file}..." if verbose?
17
+
18
+ schema = parse_schema(file)
19
+ output_result(schema)
20
+
21
+ puts "✓ Successfully parsed schema" if verbose?
22
+ rescue FileNotFoundError => e
23
+ error "File not found: #{e.message}"
24
+ exit 1
25
+ rescue ParseError => e
26
+ error "Parse error: #{e.message}"
27
+ exit 1
28
+ rescue StandardError => e
29
+ error "Unexpected error: #{e.message}"
30
+ error e.backtrace.first(5).join("\n") if verbose?
31
+ exit 1
32
+ end
33
+
34
+ private
35
+
36
+ def validate_file!(file)
37
+ raise FileNotFoundError, file unless File.exist?(file)
38
+ end
39
+
40
+ def parse_schema(file)
41
+ case File.extname(file)
42
+ when ".proto"
43
+ Unibuf.parse_schema(file)
44
+ when ".fbs"
45
+ Unibuf.parse_flatbuffers_schema(file)
46
+ else
47
+ raise InvalidArgumentError,
48
+ "Unknown schema format: #{File.extname(file)}"
49
+ end
50
+ end
51
+
52
+ def output_result(schema)
53
+ content = format_output(schema)
54
+
55
+ if output_file
56
+ File.write(output_file, content)
57
+ puts "Output written to #{output_file}" if verbose?
58
+ else
59
+ puts content
60
+ end
61
+ end
62
+
63
+ def format_output(schema)
64
+ case output_format
65
+ when "json"
66
+ require "json"
67
+ schema.to_h.to_json
68
+ when "yaml"
69
+ require "yaml"
70
+ schema.to_h.to_yaml
71
+ when "text"
72
+ format_text(schema)
73
+ else
74
+ raise InvalidArgumentError, "Unknown format: #{output_format}"
75
+ end
76
+ end
77
+
78
+ def format_text(schema)
79
+ 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?
88
+ lines << ""
89
+ lines << "Enums (#{schema.enums.size}):"
90
+ schema.enums.each do |enum|
91
+ lines << " #{enum.name} (#{enum.values.size} values)"
92
+ end
93
+ end
94
+ lines.join("\n")
95
+ end
96
+
97
+ def output_file
98
+ options[:output]
99
+ end
100
+
101
+ def output_format
102
+ options[:format] || "text"
103
+ end
104
+
105
+ def verbose?
106
+ options[:verbose]
107
+ end
108
+
109
+ def error(message)
110
+ warn "Error: #{message}"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ module Commands
5
+ # Validate command - Validate Protocol Buffer text format files
6
+ class Validate
7
+ attr_reader :options
8
+
9
+ def initialize(options = {})
10
+ @options = options
11
+ end
12
+
13
+ def run(file)
14
+ validate_file_exists!(file)
15
+
16
+ puts "Validating #{file}..." if verbose?
17
+
18
+ # Syntax validation
19
+ message = parse_and_validate_syntax(file)
20
+ puts "✓ Syntax valid" if verbose?
21
+
22
+ # Schema validation (if schema provided)
23
+ if schema_file
24
+ validate_against_schema(message)
25
+ puts "✓ Schema valid" if verbose?
26
+ end
27
+
28
+ puts "✓ #{file} is valid"
29
+ rescue FileNotFoundError => e
30
+ error "File not found: #{e.message}"
31
+ exit 1
32
+ rescue ParseError => e
33
+ error "Syntax error: #{e.message}"
34
+ exit 1
35
+ rescue ValidationError => e
36
+ error "Validation error: #{e.message}"
37
+ exit 1
38
+ rescue StandardError => e
39
+ error "Unexpected error: #{e.message}"
40
+ error e.backtrace.first(5).join("\n") if verbose?
41
+ exit 1
42
+ end
43
+
44
+ private
45
+
46
+ def validate_file_exists!(file)
47
+ raise FileNotFoundError, file unless File.exist?(file)
48
+ end
49
+
50
+ def parse_and_validate_syntax(file)
51
+ Unibuf.parse_file(file)
52
+ end
53
+
54
+ def validate_against_schema(_message)
55
+ # TODO: Implement schema validation when Proto3 parser is ready
56
+ puts "Note: Schema validation not yet implemented" if verbose?
57
+ end
58
+
59
+ def schema_file
60
+ options[:schema]
61
+ end
62
+
63
+ def verbose?
64
+ options[:verbose]
65
+ end
66
+
67
+ def strict?
68
+ options[:strict]
69
+ end
70
+
71
+ def error(message)
72
+ warn "Error: #{message}"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ # Base error class for all Unibuf errors
5
+ class Error < StandardError; end
6
+
7
+ # Parsing errors
8
+ class ParseError < Error; end
9
+ class SyntaxError < ParseError; end
10
+ class UnexpectedTokenError < ParseError; end
11
+ class UnterminatedStringError < ParseError; end
12
+
13
+ # Validation errors
14
+ class ValidationError < Error; end
15
+ class TypeValidationError < ValidationError; end
16
+ 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
36
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ module Models
5
+ # Represents an enum type definition from a Proto3 schema
6
+ class EnumDefinition
7
+ attr_reader :name, :values
8
+
9
+ def initialize(attributes = {})
10
+ @name = attributes[:name] || attributes["name"]
11
+ @values = attributes[:values] || attributes["values"] || {}
12
+ end
13
+
14
+ # Queries
15
+ def value_names
16
+ values.keys
17
+ end
18
+
19
+ def value_numbers
20
+ values.values
21
+ end
22
+
23
+ def find_value_by_name(name)
24
+ values[name]
25
+ end
26
+
27
+ def find_name_by_value(number)
28
+ values.key(number)
29
+ end
30
+
31
+ # Validation
32
+ def valid?
33
+ validate!
34
+ true
35
+ rescue ValidationError
36
+ false
37
+ end
38
+
39
+ def validate!
40
+ raise ValidationError, "Enum name required" unless name
41
+
42
+ if values.empty?
43
+ raise ValidationError,
44
+ "Enum must have at least one value"
45
+ end
46
+
47
+ # Check for duplicate values
48
+ nums = value_numbers
49
+ duplicates = nums.select { |n| nums.count(n) > 1 }.uniq
50
+ if duplicates.any?
51
+ raise ValidationError,
52
+ "Duplicate enum values: #{duplicates.join(', ')}"
53
+ end
54
+
55
+ true
56
+ end
57
+
58
+ def valid_value?(value)
59
+ case value
60
+ when String
61
+ value_names.include?(value)
62
+ when Integer
63
+ value_numbers.include?(value)
64
+ else
65
+ false
66
+ end
67
+ end
68
+
69
+ # Transformation
70
+ def to_h
71
+ {
72
+ name: name,
73
+ values: values,
74
+ }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unibuf
4
+ module Models
5
+ # Represents a field in a Protocol Buffer message
6
+ # Rich domain model with behavior, not just data
7
+ # Using plain Ruby class for polymorphic value support
8
+ class Field
9
+ attr_reader :name, :value, :is_map
10
+
11
+ def initialize(attributes = {})
12
+ @name = attributes["name"] || attributes[:name]
13
+ @value = attributes["value"] || attributes[:value]
14
+ @is_map = attributes["is_map"] || attributes[:is_map] || false
15
+ end
16
+
17
+ # Type queries - MECE classification
18
+ def message_field?
19
+ value.is_a?(Hash) && value.key?("fields")
20
+ end
21
+
22
+ def scalar_field?
23
+ !message_field? && !map_field? && !list_field?
24
+ end
25
+
26
+ def map_field?
27
+ is_map == true || (value.is_a?(Hash) && value.key?("key") && value.key?("value"))
28
+ end
29
+
30
+ def list_field?
31
+ value.is_a?(Array)
32
+ end
33
+
34
+ # Value type detection
35
+ def string_value?
36
+ value.is_a?(String)
37
+ end
38
+
39
+ def integer_value?
40
+ value.is_a?(Integer)
41
+ end
42
+
43
+ def float_value?
44
+ value.is_a?(Float)
45
+ end
46
+
47
+ def boolean_value?
48
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
49
+ end
50
+
51
+ # Value accessors with type coercion
52
+ def as_string
53
+ return value.to_s if scalar_field?
54
+
55
+ raise TypeCoercionError, "Cannot convert #{value.class} to String"
56
+ end
57
+
58
+ def as_integer
59
+ return value if integer_value?
60
+ return value.to_i if string_value? && value.match?(/^\d+$/)
61
+
62
+ raise TypeCoercionError, "Cannot convert #{value.class} to Integer"
63
+ end
64
+
65
+ def as_float
66
+ return value if float_value?
67
+ return value.to_f if string_value? && value.match?(/^\d+\.?\d*$/)
68
+
69
+ raise TypeCoercionError, "Cannot convert #{value.class} to Float"
70
+ end
71
+
72
+ def as_boolean
73
+ return value if boolean_value?
74
+ return true if string_value? && value == "true"
75
+ return false if string_value? && value == "false"
76
+
77
+ raise TypeCoercionError, "Cannot convert #{value.class} to Boolean"
78
+ end
79
+
80
+ def as_message
81
+ return Models::Message.new(value) if message_field?
82
+
83
+ raise TypeCoercionError, "Field is not a message type"
84
+ end
85
+
86
+ def as_list
87
+ return value if list_field?
88
+
89
+ raise TypeCoercionError, "Field is not a list type"
90
+ end
91
+
92
+ # Serialize to textproto format
93
+ def to_textproto(indent: 0)
94
+ indent_str = " " * indent
95
+
96
+ if message_field?
97
+ # Nested message: name { fields }
98
+ nested_msg = as_message
99
+ nested_content = nested_msg.to_textproto(indent: indent + 1)
100
+ "#{indent_str}#{name} {\n#{nested_content}\n#{indent_str}}"
101
+ elsif map_field?
102
+ # Map entry: name { key: "k" value: "v" }
103
+ key_str = format_value(value["key"])
104
+ val_str = format_value(value["value"])
105
+ "#{indent_str}#{name} {\n#{indent_str} key: #{key_str}\n#{indent_str} value: #{val_str}\n#{indent_str}}"
106
+ elsif list_field?
107
+ # List values: each item on separate line with same field name
108
+ value.map do |item|
109
+ "#{indent_str}#{name}: #{format_value(item)}"
110
+ end.join("\n")
111
+ else
112
+ # Scalar field: name: value
113
+ "#{indent_str}#{name}: #{format_value(value)}"
114
+ end
115
+ end
116
+
117
+ # Comparison
118
+ def ==(other)
119
+ return false unless other.is_a?(Field)
120
+
121
+ name == other.name && value == other.value
122
+ end
123
+
124
+ def hash
125
+ [name, value].hash
126
+ end
127
+
128
+ private
129
+
130
+ # Format a value for textproto output
131
+ def format_value(val)
132
+ case val
133
+ when String
134
+ escape_string(val)
135
+ when Integer, Float
136
+ val.to_s
137
+ when TrueClass, FalseClass
138
+ val.to_s
139
+ when Hash
140
+ # Nested message
141
+ msg = Models::Message.new(val)
142
+ "{\n#{msg.to_textproto(indent: 1)}\n}"
143
+ else
144
+ val.to_s
145
+ end
146
+ end
147
+
148
+ # Escape and quote a string for textproto
149
+ def escape_string(str)
150
+ escaped = str.gsub("\\", "\\\\")
151
+ .gsub('"', '\"')
152
+ .gsub("\n", '\\n')
153
+ .gsub("\t", '\\t')
154
+ .gsub("\r", '\\r')
155
+ "\"#{escaped}\""
156
+ end
157
+ end
158
+ end
159
+ end