sorbet-toon 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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants'
4
+ require_relative 'normalize'
5
+ require_relative 'primitives'
6
+ require_relative 'writer'
7
+
8
+ module Sorbet
9
+ module Toon
10
+ module Encode
11
+ module Encoders
12
+ module_function
13
+
14
+ ResolvedOptions = Struct.new(:indent, :delimiter, :length_marker, keyword_init: true)
15
+
16
+ def encode_value(value, options)
17
+ options = resolve_options(options)
18
+
19
+ if Normalize.json_primitive?(value)
20
+ return Primitives.encode_primitive(value, options.delimiter)
21
+ end
22
+
23
+ writer = Writer.new(options.indent)
24
+
25
+ if Normalize.json_array?(value)
26
+ encode_array(nil, value, writer, 0, options)
27
+ elsif Normalize.json_object?(value)
28
+ encode_object(value, writer, 0, options)
29
+ end
30
+
31
+ writer.to_s
32
+ end
33
+
34
+ def encode_object(object, writer, depth, options)
35
+ object.each do |key, val|
36
+ encode_key_value_pair(key, val, writer, depth, options)
37
+ end
38
+ end
39
+
40
+ def encode_key_value_pair(key, value, writer, depth, options)
41
+ encoded_key = Primitives.encode_key(key)
42
+
43
+ if Normalize.json_primitive?(value)
44
+ writer.push(depth, "#{encoded_key}: #{Primitives.encode_primitive(value, options.delimiter)}")
45
+ elsif Normalize.json_array?(value)
46
+ encode_array(key, value, writer, depth, options)
47
+ elsif Normalize.json_object?(value)
48
+ nested_keys = value.keys
49
+ if nested_keys.empty?
50
+ writer.push(depth, "#{encoded_key}:")
51
+ else
52
+ writer.push(depth, "#{encoded_key}:")
53
+ encode_object(value, writer, depth + 1, options)
54
+ end
55
+ end
56
+ end
57
+
58
+ def encode_array(key, array, writer, depth, options)
59
+ if array.empty?
60
+ header = Primitives.format_header(0, key: key, delimiter: options.delimiter, length_marker: options.length_marker)
61
+ writer.push(depth, header)
62
+ return
63
+ end
64
+
65
+ if Normalize.array_of_primitives?(array)
66
+ formatted = encode_inline_array_line(array, options.delimiter, key, options.length_marker)
67
+ writer.push(depth, formatted)
68
+ return
69
+ end
70
+
71
+ if Normalize.array_of_arrays?(array)
72
+ all_primitive = array.all? { |arr| Normalize.array_of_primitives?(arr) }
73
+ if all_primitive
74
+ encode_array_of_arrays_as_list_items(key, array, writer, depth, options)
75
+ return
76
+ end
77
+ end
78
+
79
+ if Normalize.array_of_objects?(array)
80
+ header = extract_tabular_header(array)
81
+ if header
82
+ encode_array_of_objects_as_tabular(key, array, header, writer, depth, options)
83
+ else
84
+ encode_mixed_array_as_list_items(key, array, writer, depth, options)
85
+ end
86
+ return
87
+ end
88
+
89
+ encode_mixed_array_as_list_items(key, array, writer, depth, options)
90
+ end
91
+
92
+ def encode_array_of_arrays_as_list_items(prefix, values, writer, depth, options)
93
+ header = Primitives.format_header(values.length, key: prefix, delimiter: options.delimiter, length_marker: options.length_marker)
94
+ writer.push(depth, header)
95
+
96
+ values.each do |arr|
97
+ next unless Normalize.array_of_primitives?(arr)
98
+
99
+ inline = encode_inline_array_line(arr, options.delimiter, nil, options.length_marker)
100
+ writer.push_list_item(depth + 1, inline)
101
+ end
102
+ end
103
+
104
+ def encode_inline_array_line(values, delimiter, prefix = nil, length_marker = false)
105
+ header = Primitives.format_header(values.length, key: prefix, delimiter: delimiter, length_marker: length_marker)
106
+ return header if values.empty?
107
+
108
+ joined = Primitives.encode_and_join_primitives(values, delimiter)
109
+ "#{header} #{joined}"
110
+ end
111
+
112
+ def encode_array_of_objects_as_tabular(prefix, rows, header_keys, writer, depth, options)
113
+ formatted_header = Primitives.format_header(
114
+ rows.length,
115
+ key: prefix,
116
+ fields: header_keys,
117
+ delimiter: options.delimiter,
118
+ length_marker: options.length_marker
119
+ )
120
+ writer.push(depth, formatted_header)
121
+ write_tabular_rows(rows, header_keys, writer, depth + 1, options)
122
+ end
123
+
124
+ def extract_tabular_header(rows)
125
+ return nil if rows.empty?
126
+
127
+ first_row = rows.first
128
+ header = first_row.keys
129
+ return nil if header.empty?
130
+ return header if is_tabular_array(rows, header)
131
+
132
+ nil
133
+ end
134
+
135
+ def is_tabular_array(rows, header)
136
+ rows.all? do |row|
137
+ keys = row.keys
138
+ next false unless keys.length == header.length
139
+
140
+ header.all? do |key|
141
+ row.key?(key) && Normalize.json_primitive?(row[key])
142
+ end
143
+ end
144
+ end
145
+
146
+ def write_tabular_rows(rows, header, writer, depth, options)
147
+ rows.each do |row|
148
+ values = header.map { |key| row[key] }
149
+ joined = Primitives.encode_and_join_primitives(values, options.delimiter)
150
+ writer.push(depth, joined)
151
+ end
152
+ end
153
+
154
+ def encode_mixed_array_as_list_items(prefix, items, writer, depth, options)
155
+ header = Primitives.format_header(items.length, key: prefix, delimiter: options.delimiter, length_marker: options.length_marker)
156
+ writer.push(depth, header)
157
+
158
+ items.each do |item|
159
+ encode_list_item_value(item, writer, depth + 1, options)
160
+ end
161
+ end
162
+
163
+ def encode_object_as_list_item(object, writer, depth, options)
164
+ keys = object.keys
165
+ if keys.empty?
166
+ writer.push(depth, Constants::LIST_ITEM_MARKER)
167
+ return
168
+ end
169
+
170
+ first_key = keys.first
171
+ first_value = object[first_key]
172
+ encoded_first_key = Primitives.encode_key(first_key)
173
+
174
+ if Normalize.json_primitive?(first_value)
175
+ writer.push_list_item(depth, "#{encoded_first_key}: #{Primitives.encode_primitive(first_value, options.delimiter)}")
176
+ elsif Normalize.json_array?(first_value)
177
+ handle_first_array_item(encoded_first_key, first_key, first_value, writer, depth, options)
178
+ elsif Normalize.json_object?(first_value)
179
+ nested_keys = first_value.keys
180
+ if nested_keys.empty?
181
+ writer.push_list_item(depth, "#{encoded_first_key}:")
182
+ else
183
+ writer.push_list_item(depth, "#{encoded_first_key}:")
184
+ encode_object(first_value, writer, depth + 2, options)
185
+ end
186
+ end
187
+
188
+ keys.drop(1).each do |key|
189
+ encode_key_value_pair(key, object[key], writer, depth + 1, options)
190
+ end
191
+ end
192
+
193
+ def encode_list_item_value(value, writer, depth, options)
194
+ if Normalize.json_primitive?(value)
195
+ writer.push_list_item(depth, Primitives.encode_primitive(value, options.delimiter))
196
+ elsif Normalize.json_array?(value) && Normalize.array_of_primitives?(value)
197
+ inline = encode_inline_array_line(value, options.delimiter, nil, options.length_marker)
198
+ writer.push_list_item(depth, inline)
199
+ elsif Normalize.json_object?(value)
200
+ encode_object_as_list_item(value, writer, depth, options)
201
+ end
202
+ end
203
+
204
+ def handle_first_array_item(encoded_key, raw_key, array, writer, depth, options)
205
+ if Normalize.array_of_primitives?(array)
206
+ formatted = encode_inline_array_line(array, options.delimiter, raw_key, options.length_marker)
207
+ writer.push_list_item(depth, formatted)
208
+ elsif Normalize.array_of_objects?(array)
209
+ header = extract_tabular_header(array)
210
+ if header
211
+ formatted_header = Primitives.format_header(
212
+ array.length,
213
+ key: raw_key,
214
+ fields: header,
215
+ delimiter: options.delimiter,
216
+ length_marker: options.length_marker
217
+ )
218
+ writer.push_list_item(depth, formatted_header)
219
+ write_tabular_rows(array, header, writer, depth + 1, options)
220
+ else
221
+ writer.push_list_item(depth, "#{encoded_key}[#{array.length}]:")
222
+ array.each do |item|
223
+ encode_object_as_list_item(item, writer, depth + 1, options)
224
+ end
225
+ end
226
+ else
227
+ writer.push_list_item(depth, "#{encoded_key}[#{array.length}]:")
228
+ array.each do |item|
229
+ encode_list_item_value(item, writer, depth + 1, options)
230
+ end
231
+ end
232
+ end
233
+ private_class_method :handle_first_array_item
234
+
235
+ def resolve_options(opts)
236
+ ResolvedOptions.new(
237
+ indent: opts[:indent] || 2,
238
+ delimiter: opts[:delimiter] || Constants::DEFAULT_DELIMITER,
239
+ length_marker: opts[:length_marker] || false
240
+ )
241
+ end
242
+ private_class_method :resolve_options
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'set'
5
+ require 'bigdecimal'
6
+
7
+ module Sorbet
8
+ module Toon
9
+ module Encode
10
+ module Normalize
11
+ module_function
12
+
13
+ def normalize(value)
14
+ case value
15
+ when nil
16
+ nil
17
+ when String, TrueClass, FalseClass
18
+ value
19
+ when Integer
20
+ normalize_integer(value)
21
+ when Float
22
+ normalize_float(value)
23
+ when Rational
24
+ normalize_float(value.to_f)
25
+ when BigDecimal
26
+ normalize_float(value.to_f)
27
+ when Time, DateTime
28
+ value.iso8601
29
+ when Date
30
+ value.iso8601
31
+ when Array
32
+ value.map { |item| normalize(item) }
33
+ when Set
34
+ value.map { |item| normalize(item) }
35
+ when Hash
36
+ normalize_hash(value)
37
+ else
38
+ try_custom_normalize(value)
39
+ end
40
+ end
41
+
42
+ def json_primitive?(value)
43
+ value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
44
+ end
45
+
46
+ def json_array?(value)
47
+ value.is_a?(Array)
48
+ end
49
+
50
+ def json_object?(value)
51
+ value.is_a?(Hash)
52
+ end
53
+
54
+ def array_of_primitives?(array)
55
+ array.all? { |item| json_primitive?(item) }
56
+ end
57
+
58
+ def array_of_arrays?(array)
59
+ array.all? { |item| json_array?(item) }
60
+ end
61
+
62
+ def array_of_objects?(array)
63
+ array.all? { |item| json_object?(item) }
64
+ end
65
+
66
+ def is_plain_object?(value)
67
+ value.is_a?(Hash)
68
+ end
69
+
70
+ def normalize_integer(value)
71
+ value
72
+ end
73
+ private_class_method :normalize_integer
74
+
75
+ def normalize_float(value)
76
+ return 0 if value.zero?
77
+ return nil unless value.finite?
78
+ value
79
+ end
80
+ private_class_method :normalize_float
81
+
82
+ def normalize_hash(hash)
83
+ result = {}
84
+ hash.each do |key, val|
85
+ result[key.to_s] = normalize(val)
86
+ end
87
+ result
88
+ end
89
+ private_class_method :normalize_hash
90
+
91
+ def try_custom_normalize(value)
92
+ if value.respond_to?(:to_ary)
93
+ normalize(value.to_ary)
94
+ elsif value.respond_to?(:to_hash)
95
+ normalize_hash(value.to_hash)
96
+ elsif value.respond_to?(:to_h)
97
+ normalize_hash(value.to_h)
98
+ elsif value.respond_to?(:to_s)
99
+ value.to_s
100
+ else
101
+ nil
102
+ end
103
+ end
104
+ private_class_method :try_custom_normalize
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ require_relative '../constants'
6
+ require_relative '../shared/validation'
7
+ require_relative '../shared/literal_utils'
8
+ require_relative '../shared/string_utils'
9
+
10
+ module Sorbet
11
+ module Toon
12
+ module Encode
13
+ module Primitives
14
+ module_function
15
+
16
+ def encode_primitive(value, delimiter = Constants::DEFAULT_DELIMITER)
17
+ case value
18
+ when nil
19
+ Constants::NULL_LITERAL
20
+ when TrueClass, FalseClass
21
+ value.to_s
22
+ when Integer
23
+ value.to_s
24
+ when Float
25
+ format_float(value)
26
+ when Numeric
27
+ value.to_s
28
+ else
29
+ encode_string_literal(value.to_s, delimiter)
30
+ end
31
+ end
32
+
33
+ def encode_string_literal(value, delimiter = Constants::DEFAULT_DELIMITER)
34
+ if Shared::Validation.safe_unquoted?(value, delimiter)
35
+ value
36
+ else
37
+ "\"#{Shared::StringUtils.escape_string(value)}\""
38
+ end
39
+ end
40
+
41
+ def encode_key(key)
42
+ if Shared::Validation.valid_unquoted_key?(key)
43
+ key
44
+ else
45
+ "\"#{Shared::StringUtils.escape_string(key)}\""
46
+ end
47
+ end
48
+
49
+ def encode_and_join_primitives(values, delimiter = Constants::DEFAULT_DELIMITER)
50
+ values.map { |v| encode_primitive(v, delimiter) }.join(delimiter)
51
+ end
52
+
53
+ def format_header(length, key: nil, fields: nil, delimiter: Constants::DEFAULT_DELIMITER, length_marker: false)
54
+ header = +''
55
+ header << encode_key(key) if key
56
+
57
+ header << '['
58
+ header << Constants::HASH if length_marker == Constants::HASH
59
+ header << length.to_i.to_s
60
+ header << delimiter if delimiter != Constants::DEFAULT_DELIMITER
61
+ header << ']'
62
+
63
+ if fields && !fields.empty?
64
+ encoded_fields = fields.map { |field| encode_key(field) }
65
+ header << '{'
66
+ header << encoded_fields.join(delimiter)
67
+ header << '}'
68
+ end
69
+
70
+ header << Constants::COLON
71
+ header
72
+ end
73
+
74
+ def format_float(value)
75
+ return '0' if value.zero?
76
+
77
+ decimal = BigDecimal(value.to_s)
78
+ str = decimal.to_s('F')
79
+ str = str.sub(/\.0+\z/, '')
80
+ str.sub(/(\.\d*?)0+\z/, '\1')
81
+ end
82
+ private_class_method :format_float
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants'
4
+
5
+ module Sorbet
6
+ module Toon
7
+ module Encode
8
+ class Writer
9
+ def initialize(indent_size)
10
+ @indentation_string = Constants::SPACE * indent_size
11
+ @lines = []
12
+ end
13
+
14
+ def push(depth, content)
15
+ @lines << "#{@indentation_string * depth}#{content}"
16
+ end
17
+
18
+ def push_list_item(depth, content)
19
+ push(depth, "#{Constants::LIST_ITEM_PREFIX}#{content}")
20
+ end
21
+
22
+ def to_s
23
+ @lines.join("\n")
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'normalizer'
4
+ require_relative 'codec'
5
+
6
+ module Sorbet
7
+ module Toon
8
+ module Encoder
9
+ CONFIG_KEYS = %i[indent delimiter length_marker include_type_metadata].freeze
10
+
11
+ class << self
12
+ def encode(value, config:, signature: nil, role: :output, **overrides)
13
+ config_overrides = extract_overrides(overrides, CONFIG_KEYS)
14
+ resolved = config.resolve(config_overrides)
15
+
16
+ normalized = Sorbet::Toon::Normalizer.normalize(
17
+ value,
18
+ signature: signature,
19
+ role: role,
20
+ include_type_metadata: resolved.include_type_metadata
21
+ )
22
+
23
+ Sorbet::Toon::Codec.encode(
24
+ normalized,
25
+ indent: resolved.indent,
26
+ delimiter: resolved.delimiter,
27
+ length_marker: resolved.length_marker
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def extract_overrides(options, keys)
34
+ keys.each_with_object({}) do |key, memo|
35
+ next unless options.key?(key)
36
+
37
+ memo[key] = options.delete(key)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sorbet
4
+ module Toon
5
+ module EnumExtensions
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def from_toon(payload, **options)
12
+ value = Sorbet::Toon.decode(payload, **options)
13
+ return value if value.is_a?(self)
14
+
15
+ if respond_to?(:deserialize)
16
+ deserialize(value)
17
+ else
18
+ values.find { |member| member.serialize == value }
19
+ end
20
+ end
21
+ end
22
+
23
+ def to_toon(**options)
24
+ Sorbet::Toon.encode(serialize, **options)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sorbet
4
+ module Toon
5
+ class Error < StandardError; end
6
+
7
+ class DecodeError < Error; end
8
+ class EncodeError < Error; end
9
+ end
10
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'sorbet-runtime'
5
+
6
+ require_relative 'errors'
7
+
8
+ module Sorbet
9
+ module Toon
10
+ module Normalizer
11
+ class << self
12
+ def normalize(value, signature: nil, role: :output, include_type_metadata: false)
13
+ context = {
14
+ signature: signature,
15
+ role: role,
16
+ include_type_metadata: include_type_metadata
17
+ }
18
+
19
+ normalize_value(value, context)
20
+ end
21
+
22
+ private
23
+
24
+ def normalize_value(value, context)
25
+ return nil if value.nil?
26
+
27
+ case value
28
+ when T::Struct
29
+ normalize_struct(value, context)
30
+ when T::Enum
31
+ value.serialize
32
+ when Array
33
+ value.map { |item| normalize_value(item, context) }
34
+ when Set
35
+ value.map { |item| normalize_value(item, context) }
36
+ when Hash
37
+ normalize_hash(value, context)
38
+ else
39
+ normalize_primitive(value)
40
+ end
41
+ end
42
+
43
+ def normalize_struct(struct, context)
44
+ result = {}
45
+ if context[:include_type_metadata]
46
+ result['_type'] = type_label_for(struct.class)
47
+ end
48
+
49
+ struct.class.props.each do |prop_name, prop_info|
50
+ prop_value = struct.send(prop_name)
51
+ next if prop_value.nil? && prop_info[:fully_optional]
52
+
53
+ result[prop_name.to_s] = normalize_value(prop_value, context)
54
+ end
55
+
56
+ result
57
+ end
58
+
59
+ def normalize_hash(hash, context)
60
+ hash.each_with_object({}) do |(key, value), memo|
61
+ memo[key_to_string(key)] = normalize_value(value, context)
62
+ end
63
+ end
64
+
65
+ def normalize_primitive(value)
66
+ case value
67
+ when Float
68
+ return nil unless value.finite?
69
+ return 0.0 if value.zero?
70
+
71
+ value
72
+ else
73
+ if value.respond_to?(:serialize)
74
+ value.serialize
75
+ else
76
+ value
77
+ end
78
+ end
79
+ end
80
+
81
+ def key_to_string(key)
82
+ key.to_s
83
+ end
84
+
85
+ def type_label_for(klass)
86
+ return 'AnonymousStruct' if klass.name.nil? || klass.name.empty?
87
+
88
+ klass.name.split('::').last
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end