context_spook 1.5.1 → 1.7.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/README.md +103 -12
- data/Rakefile +7 -8
- data/bin/context_spook +6 -3
- data/context_spook.gemspec +7 -8
- data/lib/context_spook/generator.rb +83 -45
- data/lib/context_spook/output_context.rb +13 -8
- data/lib/context_spook/toon/encoder.rb +628 -0
- data/lib/context_spook/toon/example.md +36 -66
- data/lib/context_spook/toon.rb +13 -15
- data/lib/context_spook/utils.rb +31 -4
- data/lib/context_spook/version.rb +1 -1
- data/spec/context_spook/generator_spec.rb +65 -1
- data/spec/context_spook/toon_encoder_spec.rb +90 -0
- data/spec/context_spook/toon_spec.rb +15 -16
- data/spec/examples/README.md +79 -0
- data/spec/examples/conversions/keyed-tabular-objects.json +19 -0
- data/spec/examples/conversions/keyed-tabular-objects.toon +4 -0
- data/spec/examples/conversions/nested-field-groups.json +28 -0
- data/spec/examples/conversions/nested-field-groups.toon +4 -0
- data/spec/examples/conversions/tabular-arrays.json +22 -0
- data/spec/examples/conversions/tabular-arrays.toon +4 -0
- data/spec/examples/invalid/delimiter-mismatch.toon +3 -0
- data/spec/examples/invalid/length-mismatch.toon +3 -0
- data/spec/examples/invalid/multiple-root-primitives.toon +2 -0
- data/spec/examples/valid/comments.toon +5 -0
- data/spec/examples/valid/delimiter-scoping.toon +5 -0
- data/spec/examples/valid/mixed-arrays.toon +5 -0
- data/spec/examples/valid/nested-objects.toon +9 -0
- data/spec/examples/valid/objects.toon +6 -0
- data/spec/examples/valid/pipe-delimiter.toon +3 -0
- data/spec/examples/valid/primitive-arrays.toon +3 -0
- data/spec/examples/valid/tab-delimiter.toon +3 -0
- data/spec/fixtures/encode/arrays-nested.json +120 -0
- data/spec/fixtures/encode/arrays-objects.json +160 -0
- data/spec/fixtures/encode/arrays-primitive.json +95 -0
- data/spec/fixtures/encode/arrays-tabular.json +182 -0
- data/spec/fixtures/encode/delimiters.json +214 -0
- data/spec/fixtures/encode/objects-keyed.json +160 -0
- data/spec/fixtures/encode/objects.json +288 -0
- data/spec/fixtures/encode/primitives.json +262 -0
- data/spec/fixtures/encode/whitespace.json +54 -0
- metadata +35 -18
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'bigdecimal'
|
|
6
|
+
|
|
7
|
+
module ContextSpook::TOON
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# The ContextSpook::TOON::Encoder class encodes Ruby data structures into
|
|
11
|
+
# TOON (Token-Oriented Object Notation) v4.4 format. It is fully conformant
|
|
12
|
+
# to the v4.4 encode fixtures from the toon-format-spec repository.
|
|
13
|
+
#
|
|
14
|
+
# It supports:
|
|
15
|
+
# - Tabular arrays with nested field groups (v4.0)
|
|
16
|
+
# - Keyed tabular form (v4.0)
|
|
17
|
+
# - Strict JSON number grammar, shortest round-trip floats (v4.2)
|
|
18
|
+
# - Delimiter-aware quoting, C0 escaping, BOM handling (v4.2)
|
|
19
|
+
# - Structural-char/combining-mark quoting (v4.4)
|
|
20
|
+
# - Streaming output via the +output:+ keyword argument
|
|
21
|
+
#
|
|
22
|
+
# @example Encoding to a string
|
|
23
|
+
# result = ContextSpook::TOON::Encoder.encode({ 'foo' => 'bar' })
|
|
24
|
+
# # => "foo: bar"
|
|
25
|
+
#
|
|
26
|
+
# @example Streaming to an IO
|
|
27
|
+
# io = StringIO.new
|
|
28
|
+
# ContextSpook::TOON::Encoder.encode({ 'foo' => 'bar' }, output: io)
|
|
29
|
+
# io.string
|
|
30
|
+
# # => "foo: bar"
|
|
31
|
+
class ContextSpook::TOON::Encoder
|
|
32
|
+
JSON_NUMBER = /\A-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?\z/.freeze
|
|
33
|
+
private_constant :JSON_NUMBER
|
|
34
|
+
SAFE_KEY = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/.freeze
|
|
35
|
+
private_constant :SAFE_KEY
|
|
36
|
+
|
|
37
|
+
attr_reader :indent_size, :delimiter
|
|
38
|
+
|
|
39
|
+
# A private Array subclass that accumulates output lines and provides a
|
|
40
|
+
# +string+ method to join them with newlines, serving as the internal
|
|
41
|
+
# buffer when no external IO is specified for streaming output.
|
|
42
|
+
class StringArray < Array
|
|
43
|
+
# Joins all accumulated lines in the array into a single string,
|
|
44
|
+
# separating each line with a newline character.
|
|
45
|
+
#
|
|
46
|
+
# This method provides the final string representation of the internally
|
|
47
|
+
# accumulated output, serving as the terminal step in the encoding
|
|
48
|
+
# pipeline when no external IO stream is provided.
|
|
49
|
+
#
|
|
50
|
+
# @return [ String ] the fully joined string representation of the
|
|
51
|
+
# accumulated array elements
|
|
52
|
+
def string
|
|
53
|
+
self * ?\n
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
private_constant :StringArray
|
|
57
|
+
|
|
58
|
+
# Creates a new encoder.
|
|
59
|
+
#
|
|
60
|
+
# @param indent [Integer] number of spaces per indent level
|
|
61
|
+
# @param delimiter [String] the field/cell separator (default ",")
|
|
62
|
+
# @param output [IO, nil] an IO to stream lines to, or +nil+ to
|
|
63
|
+
# accumulate internally
|
|
64
|
+
def initialize(indent: 2, delimiter: ',', output: nil)
|
|
65
|
+
@indent_size = indent
|
|
66
|
+
@delimiter = delimiter
|
|
67
|
+
@output = output || StringArray.new
|
|
68
|
+
@started = false if output
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Encodes +data+ into a TOON v4.4 document string (or streams it to an
|
|
72
|
+
# IO when +output:+ is given).
|
|
73
|
+
#
|
|
74
|
+
# @param data [Object] the Ruby data structure to encode
|
|
75
|
+
# @param indent [Integer] number of spaces per indent level
|
|
76
|
+
# @param delimiter [String] the field/cell separator (default ",")
|
|
77
|
+
# @param output [IO, nil] an IO to stream lines to, or +nil+ to
|
|
78
|
+
# accumulate and return the string
|
|
79
|
+
# @return [String, nil] the encoded document, or +nil+ when streaming
|
|
80
|
+
def self.encode(data, indent: 2, delimiter: ',', output: nil)
|
|
81
|
+
encoder = new(indent:, delimiter:, output:)
|
|
82
|
+
encoder.encode(data, 0)
|
|
83
|
+
encoder.to_s unless output
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Encodes +value+ (Hash, Array, or scalar) at the given depth. This is
|
|
87
|
+
# the internal entry point used by {self.encode}.
|
|
88
|
+
#
|
|
89
|
+
# @param value [Object] the data to encode
|
|
90
|
+
# @param depth [Integer] the indent depth
|
|
91
|
+
def encode(value, depth)
|
|
92
|
+
case value
|
|
93
|
+
when Hash then encode_hash(value, depth)
|
|
94
|
+
when Array then encode_array(value, depth)
|
|
95
|
+
else push(depth, encode_scalar(value))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the accumulated TOON output as a single string.
|
|
100
|
+
#
|
|
101
|
+
# @return [String] the full encoded document
|
|
102
|
+
def to_s
|
|
103
|
+
@output.string
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Writes one output line at the given indent depth, managing line separation.
|
|
109
|
+
#
|
|
110
|
+
# This method appends a line to the internal output buffer, prefixing it with
|
|
111
|
+
# indentation based on the provided depth and indent size. It handles the
|
|
112
|
+
# state of the buffer to ensure newlines are correctly inserted between
|
|
113
|
+
# lines, except after the very first line which starts the buffer.
|
|
114
|
+
#
|
|
115
|
+
# @param depth [ Integer ] the indent depth level, used to calculate the
|
|
116
|
+
# number of indentation spaces
|
|
117
|
+
# @param content [ String ] the content of the line to be written, without
|
|
118
|
+
# indentation or trailing newlines
|
|
119
|
+
#
|
|
120
|
+
# @return [ ContextSpook::TOON::Encoder ] returns self to allow for method chaining
|
|
121
|
+
def push(depth, content)
|
|
122
|
+
if @started.nil?
|
|
123
|
+
@output << (' ' * (depth * @indent_size)).concat(content)
|
|
124
|
+
elsif @started == true
|
|
125
|
+
@output << (+?\n).concat(' ' * (depth * @indent_size)).concat(content)
|
|
126
|
+
else
|
|
127
|
+
@output << (' ' * (depth * @indent_size)).concat(content)
|
|
128
|
+
@started = true
|
|
129
|
+
end
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Builds a TOON length header such as +[3]+ or +[2:]+. When the active
|
|
134
|
+
# delimiter is not the default comma it is echoed inside the brackets
|
|
135
|
+
# (e.g. +[3|]+) to signal the separator in use.
|
|
136
|
+
#
|
|
137
|
+
# @param n [Integer] the element count
|
|
138
|
+
# @param keyed [Boolean] whether this is a keyed (map) header
|
|
139
|
+
# @return [String] the length header
|
|
140
|
+
def length_header(n, keyed: false)
|
|
141
|
+
d = @delimiter == ',' ? '' : @delimiter
|
|
142
|
+
keyed ? "[#{n}:#{d}]" : "[#{n}#{d}]"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Encodes a Hash, using keyed tabular form when eligible and otherwise
|
|
146
|
+
# emitting each field in turn.
|
|
147
|
+
#
|
|
148
|
+
# @param hash [Hash] the hash to encode
|
|
149
|
+
# @param depth [Integer] the indent depth
|
|
150
|
+
def encode_hash(hash, depth)
|
|
151
|
+
if keyed_tabular?(hash)
|
|
152
|
+
encode_keyed_tabular(hash, depth)
|
|
153
|
+
else
|
|
154
|
+
hash.each { |key, value| encode_hash_pair(key, value, depth) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Encodes a single +key: value+ pair of a non-keyed hash, dispatching
|
|
159
|
+
# the value to Hash, Array, or scalar encoding as appropriate.
|
|
160
|
+
#
|
|
161
|
+
# @param key [Object] the field name
|
|
162
|
+
# @param value [Object] the field value
|
|
163
|
+
# @param depth [Integer] the indent depth of the pair
|
|
164
|
+
def encode_hash_pair(key, value, depth)
|
|
165
|
+
k = encode_key(key)
|
|
166
|
+
|
|
167
|
+
case value
|
|
168
|
+
when Hash
|
|
169
|
+
if value.empty?
|
|
170
|
+
push(depth, "#{k}:")
|
|
171
|
+
elsif keyed_tabular?(value)
|
|
172
|
+
encode_keyed_tabular(value, depth, key: k)
|
|
173
|
+
else
|
|
174
|
+
push(depth, "#{k}:")
|
|
175
|
+
encode_hash(value, depth + 1)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
when Array
|
|
179
|
+
if value.empty?
|
|
180
|
+
push(depth, "#{k}: []")
|
|
181
|
+
elsif tabular_array?(value)
|
|
182
|
+
encode_tabular(k, value, depth)
|
|
183
|
+
elsif primitive_array?(value)
|
|
184
|
+
push(depth, "#{k}#{length_header(value.size)}: " \
|
|
185
|
+
"#{value.map { |v| encode_scalar(v) }.join(@delimiter)}")
|
|
186
|
+
else
|
|
187
|
+
encode_list(k, value, depth)
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
push(depth, "#{k}: #{encode_scalar(value)}")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Encodes a root-level array, dispatching to empty, tabular, inline
|
|
195
|
+
# scalar, or list form based on the array's contents.
|
|
196
|
+
#
|
|
197
|
+
# @param array [Array] the array to encode
|
|
198
|
+
# @param depth [Integer] the indent depth
|
|
199
|
+
def encode_array(array, depth)
|
|
200
|
+
if array.empty?
|
|
201
|
+
push(depth, '[]')
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if tabular_array?(array)
|
|
206
|
+
encode_tabular(nil, array, depth)
|
|
207
|
+
elsif primitive_array?(array)
|
|
208
|
+
line = array.map { |v| encode_scalar(v) }.join(@delimiter)
|
|
209
|
+
push(depth, "#{length_header(array.size)}: #{line}")
|
|
210
|
+
else
|
|
211
|
+
encode_list(nil, array, depth)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Encodes a non-tabular array in list form: a +[n]:+ header line
|
|
216
|
+
# followed by one list item per element.
|
|
217
|
+
#
|
|
218
|
+
# @param key [String, nil] an optional field name prefix on the header
|
|
219
|
+
# @param array [Array] the elements to encode
|
|
220
|
+
# @param depth [Integer] the indent depth of the header
|
|
221
|
+
def encode_list(key, array, depth)
|
|
222
|
+
label = key ? "#{key}#{length_header(array.size)}:" : \
|
|
223
|
+
"#{length_header(array.size)}:"
|
|
224
|
+
push(depth, label)
|
|
225
|
+
array.each { |item| encode_list_item(item, depth + 1) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Encodes a single list item, dispatching on its type: Hashes expand
|
|
229
|
+
# into a field block, Arrays into a nested list, scalars into a plain
|
|
230
|
+
# bullet value.
|
|
231
|
+
#
|
|
232
|
+
# @param item [Object] the list item
|
|
233
|
+
# @param depth [Integer] the indent depth of the bullet line
|
|
234
|
+
def encode_list_item(item, depth)
|
|
235
|
+
case item
|
|
236
|
+
when Hash
|
|
237
|
+
if item.empty?
|
|
238
|
+
push(depth, '-')
|
|
239
|
+
else
|
|
240
|
+
encode_list_hash(item, depth)
|
|
241
|
+
end
|
|
242
|
+
when Array
|
|
243
|
+
encode_list_array(item, depth)
|
|
244
|
+
else
|
|
245
|
+
push(depth, "- #{encode_scalar(item)}")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Encodes a Hash that is a list item: the first field is emitted on the
|
|
250
|
+
# bullet line and the remaining fields follow at the sibling indent.
|
|
251
|
+
#
|
|
252
|
+
# @param hash [Hash] the list-item hash
|
|
253
|
+
# @param depth [Integer] the indent depth of the bullet line
|
|
254
|
+
def encode_list_hash(hash, depth)
|
|
255
|
+
first = true
|
|
256
|
+
hash.each do |k, v|
|
|
257
|
+
if first
|
|
258
|
+
push_list_field(k, v, depth, hyphen: true)
|
|
259
|
+
first = false
|
|
260
|
+
else
|
|
261
|
+
push_list_field(k, v, depth + 1, hyphen: false)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Encodes one field of a list item. The +hyphen:+ flag controls whether
|
|
267
|
+
# the field sits on the "- " bullet line and whether its nested content
|
|
268
|
+
# is indented one extra level to clear the bullet.
|
|
269
|
+
#
|
|
270
|
+
# @param key [Object] the field name
|
|
271
|
+
# @param value [Object] the field value
|
|
272
|
+
# @param depth [Integer] the indent depth of the field line
|
|
273
|
+
# @param hyphen [Boolean] whether this field is on the bullet line
|
|
274
|
+
def push_list_field(key, value, depth, hyphen:)
|
|
275
|
+
k = encode_key(key)
|
|
276
|
+
prefix = hyphen ? '- ' : ''
|
|
277
|
+
# Nested content under a hyphen line gets one extra indent level
|
|
278
|
+
# because "- " occupies one indent step.
|
|
279
|
+
child_depth = hyphen ? depth + 2 : depth + 1
|
|
280
|
+
|
|
281
|
+
case value
|
|
282
|
+
when Hash
|
|
283
|
+
if value.empty?
|
|
284
|
+
push(depth, "#{prefix}#{k}:")
|
|
285
|
+
elsif keyed_tabular?(value)
|
|
286
|
+
rd = hyphen ? depth + 2 : depth + 1
|
|
287
|
+
encode_keyed_tabular(value, depth, key: "#{prefix}#{k}",
|
|
288
|
+
row_depth: rd)
|
|
289
|
+
else
|
|
290
|
+
push(depth, "#{prefix}#{k}:")
|
|
291
|
+
encode_hash(value, child_depth)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
when Array
|
|
295
|
+
if value.empty?
|
|
296
|
+
push(depth, "#{prefix}#{k}: []")
|
|
297
|
+
elsif tabular_array?(value)
|
|
298
|
+
row_depth = hyphen ? depth + 2 : depth + 1
|
|
299
|
+
encode_tabular("#{prefix}#{k}", value, depth, row_depth:)
|
|
300
|
+
elsif primitive_array?(value)
|
|
301
|
+
line = value.map { |v| encode_scalar(v) }.join(@delimiter)
|
|
302
|
+
push(depth, "#{prefix}#{k}#{length_header(value.size)}: #{line}")
|
|
303
|
+
else
|
|
304
|
+
push(depth, "#{prefix}#{k}#{length_header(value.size)}:")
|
|
305
|
+
value.each { |item| encode_list_item(item, child_depth) }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
else
|
|
309
|
+
push(depth, "#{prefix}#{k}: #{encode_scalar(value)}")
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Encodes an anonymous array that is itself a list item, prefixed with
|
|
314
|
+
# a hyphen. Scalar arrays use inline form; anything else expands into
|
|
315
|
+
# nested list items.
|
|
316
|
+
#
|
|
317
|
+
# @param array [Array] the list-item array
|
|
318
|
+
# @param depth [Integer] the indent depth of the hyphen line
|
|
319
|
+
def encode_list_array(array, depth)
|
|
320
|
+
if array.empty?
|
|
321
|
+
push(depth, "- #{length_header(0)}:")
|
|
322
|
+
elsif primitive_array?(array)
|
|
323
|
+
line = array.map { |v| encode_scalar(v) }.join(@delimiter)
|
|
324
|
+
push(depth, "- #{length_header(array.size)}: #{line}")
|
|
325
|
+
else
|
|
326
|
+
push(depth, "- #{length_header(array.size)}:")
|
|
327
|
+
array.each { |item| encode_list_item(item, depth + 1) }
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Encodes an array of uniform Hashes in tabular form: a header line
|
|
332
|
+
# +[n]{fields}:+ followed by one delimited row per element.
|
|
333
|
+
#
|
|
334
|
+
# @param key [String, nil] an optional field name prefix on the header
|
|
335
|
+
# @param array [Array<Hash>] the rows to encode
|
|
336
|
+
# @param depth [Integer] the indent depth for the header
|
|
337
|
+
# @param row_depth [Integer] the indent depth for the data rows
|
|
338
|
+
def encode_tabular(key, array, depth, row_depth: depth + 1)
|
|
339
|
+
header, paths = build_field_spec(array)
|
|
340
|
+
label = key ? "#{key}#{length_header(array.size)}{#{header}}:" : \
|
|
341
|
+
"#{length_header(array.size)}{#{header}}:"
|
|
342
|
+
push(depth, label)
|
|
343
|
+
|
|
344
|
+
array.each do |row|
|
|
345
|
+
values = paths.map do |path|
|
|
346
|
+
cell = row.dig(*path)
|
|
347
|
+
encode_cell(cell)
|
|
348
|
+
end
|
|
349
|
+
push(row_depth, values.join(@delimiter))
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Encodes a hash in keyed tabular form: a header line
|
|
354
|
+
# +[n:]{fields}:+ followed by one +key: v1,v2+ row per entry.
|
|
355
|
+
#
|
|
356
|
+
# @param hash [Hash] the hash to encode
|
|
357
|
+
# @param depth [Integer] the indent depth of the header
|
|
358
|
+
# @param key [String, nil] an optional field name prefix on the header
|
|
359
|
+
# @param row_depth [Integer, nil] the indent depth for entry rows
|
|
360
|
+
def encode_keyed_tabular(hash, depth, key: nil, row_depth: nil)
|
|
361
|
+
rd = row_depth || depth + 1
|
|
362
|
+
values = hash.values
|
|
363
|
+
header, paths = build_field_spec(values)
|
|
364
|
+
lh = length_header(hash.size, keyed: true)
|
|
365
|
+
label = key ? "#{key}#{lh}{#{header}}:" : "#{lh}{#{header}}:"
|
|
366
|
+
push(depth, label)
|
|
367
|
+
|
|
368
|
+
hash.each do |k, row|
|
|
369
|
+
values_line = paths.map do |path|
|
|
370
|
+
cell = row.dig(*path)
|
|
371
|
+
encode_cell(cell)
|
|
372
|
+
end
|
|
373
|
+
push(rd, "#{encode_key(k)}: #{values_line.join(@delimiter)}")
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Builds the field-group header and per-field access paths for a set of
|
|
378
|
+
# row hashes. Nested uniform Hash fields collapse into +{name{...}}+
|
|
379
|
+
# groups; the returned paths are key chains used to fetch each cell.
|
|
380
|
+
#
|
|
381
|
+
# @param rows [Array<Hash>] the row hashes
|
|
382
|
+
# @return [Array(String, Array<Array<String>>)] the joined header and
|
|
383
|
+
# the list of key-path chains
|
|
384
|
+
def build_field_spec(rows)
|
|
385
|
+
keys = rows.first.keys
|
|
386
|
+
parts = []
|
|
387
|
+
paths = []
|
|
388
|
+
|
|
389
|
+
keys.each do |key|
|
|
390
|
+
sub = rows.map { |r| r[key] }
|
|
391
|
+
ek = encode_key(key)
|
|
392
|
+
|
|
393
|
+
if sub.all? { |v| v.is_a?(Hash) && !v.empty? } &&
|
|
394
|
+
uniform_hashes?(sub)
|
|
395
|
+
sub_header, sub_paths = build_field_spec(sub)
|
|
396
|
+
parts.push("#{ek}{#{sub_header}}")
|
|
397
|
+
sub_paths.each { |p| paths.push([key] + p) }
|
|
398
|
+
else
|
|
399
|
+
parts.push(ek)
|
|
400
|
+
paths.push([key])
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
[parts.join(@delimiter), paths]
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Encodes a single tabular cell. Nested Hash/Array cells are JSON-
|
|
408
|
+
# encoded and quoted; +nil+ becomes "null"; everything else goes
|
|
409
|
+
# through the scalar encoder.
|
|
410
|
+
#
|
|
411
|
+
# @param cell [Object] the cell value
|
|
412
|
+
# @return [String] the encoded cell
|
|
413
|
+
def encode_cell(cell)
|
|
414
|
+
case cell
|
|
415
|
+
when Hash, Array
|
|
416
|
+
"\"#{JSON.generate(cell)}\""
|
|
417
|
+
when nil
|
|
418
|
+
'null'
|
|
419
|
+
else
|
|
420
|
+
encode_scalar(cell)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Reports whether +hash+ is eligible for keyed tabular form: at least
|
|
425
|
+
# two entries, all values non-empty uniform Hashes, none containing
|
|
426
|
+
# Array values.
|
|
427
|
+
#
|
|
428
|
+
# @param hash [Hash] the candidate hash
|
|
429
|
+
# @return [Boolean] +true+ when keyed tabular form applies
|
|
430
|
+
def keyed_tabular?(hash)
|
|
431
|
+
return false if hash.size < 2
|
|
432
|
+
values = hash.values
|
|
433
|
+
return false unless values.all? { |v| v.is_a?(Hash) && !v.empty? }
|
|
434
|
+
return false unless uniform_hashes?(values)
|
|
435
|
+
!values.any? { |v| hash_has_array?(v) }
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Recursively reports whether any value inside +hash+ (or nested
|
|
439
|
+
# hashes) is an Array.
|
|
440
|
+
#
|
|
441
|
+
# @param hash [Hash] the hash to inspect
|
|
442
|
+
# @return [Boolean] +true+ when an Array is present
|
|
443
|
+
def hash_has_array?(hash)
|
|
444
|
+
hash.values.any? do |v|
|
|
445
|
+
case v
|
|
446
|
+
when Array then true
|
|
447
|
+
when Hash then hash_has_array?(v)
|
|
448
|
+
else false
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Reports whether +array+ is an array of non-empty, key-uniform Hashes
|
|
454
|
+
# whose fields can be flattened into tabular columns.
|
|
455
|
+
#
|
|
456
|
+
# @param array [Array] the candidate array
|
|
457
|
+
# @return [Boolean] +true+ when the array is tabular-eligible
|
|
458
|
+
def tabular_array?(array)
|
|
459
|
+
!array.empty? &&
|
|
460
|
+
array.all? { |v| v.is_a?(Hash) && !v.empty? } &&
|
|
461
|
+
uniform_hashes?(array) &&
|
|
462
|
+
tabular_leaves?(array)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Recursively reports whether each field's values across +hashes+ are
|
|
466
|
+
# either all scalars or all uniform nested Hashes without Array
|
|
467
|
+
# values, so they can be flattened into tabular columns.
|
|
468
|
+
#
|
|
469
|
+
# @param hashes [Array<Hash>] the hashes to inspect
|
|
470
|
+
# @return [Boolean] +true+ when tabular flattening is possible
|
|
471
|
+
def tabular_leaves?(hashes)
|
|
472
|
+
keys = hashes.first.keys
|
|
473
|
+
keys.all? do |key|
|
|
474
|
+
values = hashes.map { |h| h[key] }
|
|
475
|
+
if values.all? { |v| v.nil? || v.is_a?(String) ||
|
|
476
|
+
v.is_a?(Numeric) || v.is_a?(TrueClass) || v.is_a?(FalseClass) }
|
|
477
|
+
true
|
|
478
|
+
elsif values.all? { |v| v.is_a?(Hash) && !v.empty? } &&
|
|
479
|
+
uniform_hashes?(values) &&
|
|
480
|
+
!values.any? { |v| v.values.any? { |x| x.is_a?(Array) } }
|
|
481
|
+
tabular_leaves?(values)
|
|
482
|
+
else
|
|
483
|
+
false
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Reports whether every hash in +hashes+ has the same set of keys
|
|
489
|
+
# (order-insensitive), a prerequisite for tabular encoding.
|
|
490
|
+
#
|
|
491
|
+
# @param hashes [Array<Hash>] the hashes to compare
|
|
492
|
+
# @return [Boolean] +true+ when all key sets are identical
|
|
493
|
+
def uniform_hashes?(hashes)
|
|
494
|
+
first = hashes.first
|
|
495
|
+
keys = first.keys
|
|
496
|
+
size = first.size
|
|
497
|
+
hashes.all? { |h| h.size == size && keys.all? { |k| h.key?(k) } }
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Reports whether every element of +array+ is a TOON scalar (nil,
|
|
501
|
+
# String, Numeric, or boolean), i.e. it can use inline form.
|
|
502
|
+
#
|
|
503
|
+
# @param array [Array] the candidate array
|
|
504
|
+
# @return [Boolean] +true+ when all elements are scalars
|
|
505
|
+
def primitive_array?(array)
|
|
506
|
+
array.all? do |v|
|
|
507
|
+
v.nil? || v.is_a?(String) || v.is_a?(Numeric) ||
|
|
508
|
+
v.is_a?(TrueClass) || v.is_a?(FalseClass)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Encodes a single scalar value (nil, boolean, number, or string) into
|
|
513
|
+
# its TOON representation.
|
|
514
|
+
#
|
|
515
|
+
# @param value [Object] the scalar to encode
|
|
516
|
+
# @return [String] the encoded scalar
|
|
517
|
+
def encode_scalar(value)
|
|
518
|
+
case value
|
|
519
|
+
when nil then 'null'
|
|
520
|
+
when true then 'true'
|
|
521
|
+
when false then 'false'
|
|
522
|
+
when Integer then value.to_s
|
|
523
|
+
when Float then encode_float(value)
|
|
524
|
+
else encode_string(value.to_s)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Encodes a +Float+ using its shortest round-trippable decimal form.
|
|
529
|
+
# Whole-number floats become integers, zero becomes "0", and exponent
|
|
530
|
+
# notation is expanded via BigDecimal to a plain decimal.
|
|
531
|
+
#
|
|
532
|
+
# @param f [Float] the float value
|
|
533
|
+
# @return [String] the encoded number
|
|
534
|
+
def encode_float(f)
|
|
535
|
+
return '0' if f.zero?
|
|
536
|
+
fi = f.to_i
|
|
537
|
+
return fi.to_s if f == fi && f.abs < 1e15
|
|
538
|
+
fs = f.to_s
|
|
539
|
+
return fs unless fs.match?(/e/i)
|
|
540
|
+
BigDecimal(fs).to_s('F')
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Encodes a scalar string value. The string is UTF-8-scrubbed, then
|
|
544
|
+
# emitted bare when safe or double-quoted with escaping otherwise.
|
|
545
|
+
#
|
|
546
|
+
# @param str [String] the raw string value
|
|
547
|
+
# @return [String] the encoded string
|
|
548
|
+
def encode_string(str)
|
|
549
|
+
return '""' if str.empty?
|
|
550
|
+
|
|
551
|
+
str = str.scrub unless str.valid_encoding?
|
|
552
|
+
|
|
553
|
+
if safe_unquoted?(str)
|
|
554
|
+
str
|
|
555
|
+
else
|
|
556
|
+
"\"#{escape_string(str)}\""
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Encodes a hash key per the TOON key-encoding rules. Bare word-like
|
|
561
|
+
# keys are emitted as-is; anything resembling a number or literal, or
|
|
562
|
+
# containing structural characters, is quoted and escaped.
|
|
563
|
+
#
|
|
564
|
+
# @param key [Object] the raw hash key
|
|
565
|
+
# @return [String] the encoded key
|
|
566
|
+
def encode_key(key)
|
|
567
|
+
key = key.to_s
|
|
568
|
+
key = key.scrub unless key.empty? || key.valid_encoding?
|
|
569
|
+
|
|
570
|
+
if key.match?(SAFE_KEY) && !key.match?(JSON_NUMBER)
|
|
571
|
+
key
|
|
572
|
+
else
|
|
573
|
+
"\"#{escape_string(key)}\""
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
UNSAFE_STRING = /
|
|
578
|
+
\A\# # comment prefix (§7.2)
|
|
579
|
+
| \A\xEF\xBB\xBF[0-9.+-] # BOM + digit: decoders strip BOM (§12)
|
|
580
|
+
| \A\s # leadingwhitespace (§7.1)
|
|
581
|
+
| \s\z # trailing whitespace (§7.1)
|
|
582
|
+
| [\x00-\x1F:\[\]{}"\\] # controls, reserved, structural (§7.1)
|
|
583
|
+
| #{JSON_NUMBER.source} # full JSON number (§7.3)
|
|
584
|
+
| \A(?:true|false|null)\z # JSON literal (§7.3)
|
|
585
|
+
| \A[+-]?\d # number-like prefix (§7.3)
|
|
586
|
+
| \A- # list item marker (§7.1)
|
|
587
|
+
/x.freeze
|
|
588
|
+
private_constant :UNSAFE_STRING
|
|
589
|
+
|
|
590
|
+
# Reports whether +str+ can be emitted as an unquoted TOON value.
|
|
591
|
+
# The UNSAFE_STRING regex covers all static structural conflicts;
|
|
592
|
+
# the active delimiter is checked separately (instance state).
|
|
593
|
+
#
|
|
594
|
+
# @param str [String] the scrubbed candidate string
|
|
595
|
+
# @return [Boolean] +true+ when the string needs no quoting
|
|
596
|
+
def safe_unquoted?(str)
|
|
597
|
+
return false if str.match?(UNSAFE_STRING)
|
|
598
|
+
return false if str.include?(@delimiter)
|
|
599
|
+
true
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
ESCAPE_STRING = {
|
|
603
|
+
?\0 => '\\u0000'.freeze,
|
|
604
|
+
?\a => '\\u0007'.freeze,
|
|
605
|
+
?\b => '\\u0008'.freeze,
|
|
606
|
+
?\t => '\\t'.freeze,
|
|
607
|
+
?\n => '\\n'.freeze,
|
|
608
|
+
?\v => '\\u000b'.freeze,
|
|
609
|
+
?\f => '\\u000c'.freeze,
|
|
610
|
+
?\r => '\\r'.freeze,
|
|
611
|
+
?\e => '\\u001b'.freeze,
|
|
612
|
+
?\\ => '\\\\'.freeze,
|
|
613
|
+
?" => '\\"'.freeze,
|
|
614
|
+
}
|
|
615
|
+
ESCAPE_STRING.default_proc = -> _, c { format('\\u%04x', c.ord) }
|
|
616
|
+
ESCAPE_STRING.freeze
|
|
617
|
+
private_constant :ESCAPE_STRING
|
|
618
|
+
|
|
619
|
+
# Escapes a string for safe inclusion inside a double-quoted TOON
|
|
620
|
+
# value. Backslashes, double quotes, and C0 control characters are
|
|
621
|
+
# escaped; remaining control characters are emitted as \uXXXX.
|
|
622
|
+
#
|
|
623
|
+
# @param str [String] the raw string to escape
|
|
624
|
+
# @return [String] the escaped string (without surrounding quotes)
|
|
625
|
+
def escape_string(str)
|
|
626
|
+
str.gsub(/[\\"\n\r\t\x00-\x08\x0B\x0C\x0E-\x1F]/, ESCAPE_STRING)
|
|
627
|
+
end
|
|
628
|
+
end
|