cton 0.1.0 → 0.1.1

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