cton 0.1.0 → 0.2.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 +4 -4
- data/.rubocop.yml +13 -0
- data/CHANGELOG.md +31 -0
- data/README.md +158 -38
- data/lib/cton/decoder.rb +327 -0
- data/lib/cton/encoder.rb +307 -0
- data/lib/cton/version.rb +1 -1
- data/lib/cton.rb +17 -559
- data/sig/cton.rbs +76 -4
- metadata +5 -2
data/lib/cton/encoder.rb
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "time"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module Cton
|
|
8
|
+
class Encoder
|
|
9
|
+
SAFE_TOKEN = /\A[0-9A-Za-z_.:-]+\z/
|
|
10
|
+
NUMERIC_TOKEN = /\A-?(?:\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?\z/
|
|
11
|
+
RESERVED_LITERALS = %w[true false null].freeze
|
|
12
|
+
FLOAT_DECIMAL_PRECISION = Float::DIG
|
|
13
|
+
|
|
14
|
+
def initialize(separator: "\n", pretty: false)
|
|
15
|
+
@separator = separator || ""
|
|
16
|
+
@pretty = pretty
|
|
17
|
+
@indent_level = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def encode(payload, io: nil)
|
|
21
|
+
@io = io || StringIO.new
|
|
22
|
+
encode_root(payload)
|
|
23
|
+
@io.string if @io.is_a?(StringIO)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :separator, :io, :pretty, :indent_level
|
|
29
|
+
|
|
30
|
+
def encode_root(value)
|
|
31
|
+
case value
|
|
32
|
+
when Hash
|
|
33
|
+
first = true
|
|
34
|
+
value.each do |key, nested|
|
|
35
|
+
io << separator unless first
|
|
36
|
+
encode_top_level_pair(key, nested)
|
|
37
|
+
first = false
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
encode_value(value, context: :standalone)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def encode_top_level_pair(key, value)
|
|
45
|
+
io << format_key(key)
|
|
46
|
+
encode_value(value, context: :top_pair)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def encode_value(value, context:)
|
|
50
|
+
if defined?(Set) && value.is_a?(Set)
|
|
51
|
+
value = value.to_a
|
|
52
|
+
elsif defined?(OpenStruct) && value.is_a?(OpenStruct)
|
|
53
|
+
value = value.to_h
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
case value
|
|
57
|
+
when Hash
|
|
58
|
+
encode_object(value)
|
|
59
|
+
when Array
|
|
60
|
+
encode_array(value)
|
|
61
|
+
else
|
|
62
|
+
io << "=" if context == :top_pair
|
|
63
|
+
encode_scalar(value)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def encode_object(hash)
|
|
68
|
+
if hash.empty?
|
|
69
|
+
io << "()"
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
io << "("
|
|
74
|
+
indent if pretty
|
|
75
|
+
first = true
|
|
76
|
+
hash.each do |key, value|
|
|
77
|
+
if first
|
|
78
|
+
first = false
|
|
79
|
+
else
|
|
80
|
+
io << ","
|
|
81
|
+
newline if pretty
|
|
82
|
+
end
|
|
83
|
+
io << format_key(key) << "="
|
|
84
|
+
encode_value(value, context: :object)
|
|
85
|
+
end
|
|
86
|
+
outdent if pretty
|
|
87
|
+
io << ")"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def encode_array(list)
|
|
91
|
+
length = list.length
|
|
92
|
+
if length.zero?
|
|
93
|
+
io << "[0]="
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
io << "[" << length.to_s << "]"
|
|
98
|
+
|
|
99
|
+
if table_candidate?(list)
|
|
100
|
+
encode_table(list)
|
|
101
|
+
else
|
|
102
|
+
io << "="
|
|
103
|
+
if list.all? { |value| scalar?(value) }
|
|
104
|
+
encode_scalar_list(list)
|
|
105
|
+
else
|
|
106
|
+
encode_mixed_list(list)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def encode_table(rows)
|
|
112
|
+
header = rows.first.keys
|
|
113
|
+
io << "{"
|
|
114
|
+
io << header.map { |key| format_key(key) }.join(",")
|
|
115
|
+
io << "}="
|
|
116
|
+
|
|
117
|
+
indent if pretty
|
|
118
|
+
first_row = true
|
|
119
|
+
rows.each do |row|
|
|
120
|
+
if first_row
|
|
121
|
+
first_row = false
|
|
122
|
+
else
|
|
123
|
+
io << ";"
|
|
124
|
+
newline if pretty
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
first_col = true
|
|
128
|
+
header.each do |field|
|
|
129
|
+
io << "," unless first_col
|
|
130
|
+
encode_scalar(row.fetch(field))
|
|
131
|
+
first_col = false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
outdent if pretty
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def encode_scalar_list(list)
|
|
138
|
+
if pretty
|
|
139
|
+
indent
|
|
140
|
+
first = true
|
|
141
|
+
list.each do |value|
|
|
142
|
+
if first
|
|
143
|
+
first = false
|
|
144
|
+
else
|
|
145
|
+
io << ","
|
|
146
|
+
newline
|
|
147
|
+
end
|
|
148
|
+
encode_scalar(value)
|
|
149
|
+
end
|
|
150
|
+
outdent
|
|
151
|
+
else
|
|
152
|
+
first = true
|
|
153
|
+
list.each do |value|
|
|
154
|
+
io << "," unless first
|
|
155
|
+
encode_scalar(value)
|
|
156
|
+
first = false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def encode_mixed_list(list)
|
|
162
|
+
indent if pretty
|
|
163
|
+
first = true
|
|
164
|
+
list.each do |value|
|
|
165
|
+
if first
|
|
166
|
+
first = false
|
|
167
|
+
else
|
|
168
|
+
io << ","
|
|
169
|
+
newline if pretty
|
|
170
|
+
end
|
|
171
|
+
encode_value(value, context: :array)
|
|
172
|
+
end
|
|
173
|
+
outdent if pretty
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def encode_scalar(value)
|
|
177
|
+
case value
|
|
178
|
+
when String
|
|
179
|
+
encode_string(value)
|
|
180
|
+
when TrueClass, FalseClass
|
|
181
|
+
io << (value ? "true" : "false")
|
|
182
|
+
when NilClass
|
|
183
|
+
io << "null"
|
|
184
|
+
when Numeric
|
|
185
|
+
io << format_number(value)
|
|
186
|
+
when Time, Date
|
|
187
|
+
encode_string(value.iso8601)
|
|
188
|
+
else
|
|
189
|
+
raise EncodeError, "Unsupported value: #{value.class}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def encode_string(value)
|
|
194
|
+
io << if value.empty?
|
|
195
|
+
'""'
|
|
196
|
+
elsif string_needs_quotes?(value)
|
|
197
|
+
quote_string(value)
|
|
198
|
+
else
|
|
199
|
+
value
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def format_number(value)
|
|
204
|
+
case value
|
|
205
|
+
when Float
|
|
206
|
+
return "null" if value.nan? || value.infinite?
|
|
207
|
+
|
|
208
|
+
normalize_decimal_string(float_decimal_string(value))
|
|
209
|
+
when Integer
|
|
210
|
+
value.to_s
|
|
211
|
+
else
|
|
212
|
+
if defined?(BigDecimal) && value.is_a?(BigDecimal)
|
|
213
|
+
normalize_decimal_string(value.to_s("F"))
|
|
214
|
+
else
|
|
215
|
+
value.to_s
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def normalize_decimal_string(string)
|
|
221
|
+
stripped = string.start_with?("+") ? string[1..] : string
|
|
222
|
+
return "0" if zero_string?(stripped)
|
|
223
|
+
|
|
224
|
+
if stripped.include?(".")
|
|
225
|
+
stripped = stripped.sub(/0+\z/, "")
|
|
226
|
+
stripped = stripped.sub(/\.\z/, "")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
stripped
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def zero_string?(string)
|
|
233
|
+
string.match?(/\A-?0+(?:\.0+)?\z/)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def float_decimal_string(value)
|
|
237
|
+
if defined?(BigDecimal)
|
|
238
|
+
BigDecimal(value.to_s).to_s("F")
|
|
239
|
+
else
|
|
240
|
+
Kernel.format("%.#{FLOAT_DECIMAL_PRECISION}f", value)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def format_key(key)
|
|
245
|
+
key_string = key.to_s
|
|
246
|
+
raise EncodeError, "Invalid key: #{key_string.inspect}" unless SAFE_TOKEN.match?(key_string)
|
|
247
|
+
|
|
248
|
+
key_string
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def string_needs_quotes?(value)
|
|
252
|
+
return true unless SAFE_TOKEN.match?(value)
|
|
253
|
+
|
|
254
|
+
RESERVED_LITERALS.include?(value) || numeric_like?(value)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def numeric_like?(value)
|
|
258
|
+
NUMERIC_TOKEN.match?(value)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def quote_string(value)
|
|
262
|
+
"\"#{escape_string(value)}\""
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def escape_string(value)
|
|
266
|
+
value.gsub(/["\\\n\r\t]/) do |char|
|
|
267
|
+
case char
|
|
268
|
+
when "\n" then "\\n"
|
|
269
|
+
when "\r" then "\\r"
|
|
270
|
+
when "\t" then "\\t"
|
|
271
|
+
else
|
|
272
|
+
"\\#{char}"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def scalar?(value)
|
|
278
|
+
value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.nil? || value.is_a?(Time) || value.is_a?(Date)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def table_candidate?(rows)
|
|
282
|
+
return false if rows.empty?
|
|
283
|
+
|
|
284
|
+
first = rows.first
|
|
285
|
+
return false unless first.is_a?(Hash) && !first.empty?
|
|
286
|
+
|
|
287
|
+
keys = first.keys
|
|
288
|
+
rows.all? do |row|
|
|
289
|
+
row.is_a?(Hash) && row.keys == keys && row.values.all? { |val| scalar?(val) }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def indent
|
|
294
|
+
@indent_level += 1
|
|
295
|
+
newline
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def outdent
|
|
299
|
+
@indent_level -= 1
|
|
300
|
+
newline
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def newline
|
|
304
|
+
io << "\n" << (" " * indent_level)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
data/lib/cton/version.rb
CHANGED