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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +16 -0
- data/.rubocop_todo.yml +498 -0
- data/README.adoc +490 -0
- data/Rakefile +12 -0
- data/exe/unibuf +7 -0
- data/lib/unibuf/cli.rb +128 -0
- data/lib/unibuf/commands/convert.rb +121 -0
- data/lib/unibuf/commands/parse.rb +85 -0
- data/lib/unibuf/commands/schema.rb +114 -0
- data/lib/unibuf/commands/validate.rb +76 -0
- data/lib/unibuf/errors.rb +36 -0
- data/lib/unibuf/models/enum_definition.rb +78 -0
- data/lib/unibuf/models/field.rb +159 -0
- data/lib/unibuf/models/field_definition.rb +119 -0
- data/lib/unibuf/models/message.rb +203 -0
- data/lib/unibuf/models/message_definition.rb +102 -0
- data/lib/unibuf/models/schema.rb +67 -0
- data/lib/unibuf/models/values/base_value.rb +78 -0
- data/lib/unibuf/models/values/list_value.rb +114 -0
- data/lib/unibuf/models/values/map_value.rb +103 -0
- data/lib/unibuf/models/values/message_value.rb +70 -0
- data/lib/unibuf/models/values/scalar_value.rb +113 -0
- data/lib/unibuf/parsers/binary/wire_format_parser.rb +43 -0
- data/lib/unibuf/parsers/proto3/grammar.rb +149 -0
- data/lib/unibuf/parsers/proto3/processor.rb +188 -0
- data/lib/unibuf/parsers/textproto/grammar.rb +141 -0
- data/lib/unibuf/parsers/textproto/parser.rb +92 -0
- data/lib/unibuf/parsers/textproto/processor.rb +136 -0
- data/lib/unibuf/validators/schema_validator.rb +110 -0
- data/lib/unibuf/validators/type_validator.rb +122 -0
- data/lib/unibuf/version.rb +5 -0
- data/lib/unibuf.rb +207 -0
- data/sig/unibuf.rbs +4 -0
- metadata +139 -0
|
@@ -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
|