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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cton
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end