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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +103 -12
  3. data/Rakefile +7 -8
  4. data/bin/context_spook +6 -3
  5. data/context_spook.gemspec +7 -8
  6. data/lib/context_spook/generator.rb +83 -45
  7. data/lib/context_spook/output_context.rb +13 -8
  8. data/lib/context_spook/toon/encoder.rb +628 -0
  9. data/lib/context_spook/toon/example.md +36 -66
  10. data/lib/context_spook/toon.rb +13 -15
  11. data/lib/context_spook/utils.rb +31 -4
  12. data/lib/context_spook/version.rb +1 -1
  13. data/spec/context_spook/generator_spec.rb +65 -1
  14. data/spec/context_spook/toon_encoder_spec.rb +90 -0
  15. data/spec/context_spook/toon_spec.rb +15 -16
  16. data/spec/examples/README.md +79 -0
  17. data/spec/examples/conversions/keyed-tabular-objects.json +19 -0
  18. data/spec/examples/conversions/keyed-tabular-objects.toon +4 -0
  19. data/spec/examples/conversions/nested-field-groups.json +28 -0
  20. data/spec/examples/conversions/nested-field-groups.toon +4 -0
  21. data/spec/examples/conversions/tabular-arrays.json +22 -0
  22. data/spec/examples/conversions/tabular-arrays.toon +4 -0
  23. data/spec/examples/invalid/delimiter-mismatch.toon +3 -0
  24. data/spec/examples/invalid/length-mismatch.toon +3 -0
  25. data/spec/examples/invalid/multiple-root-primitives.toon +2 -0
  26. data/spec/examples/valid/comments.toon +5 -0
  27. data/spec/examples/valid/delimiter-scoping.toon +5 -0
  28. data/spec/examples/valid/mixed-arrays.toon +5 -0
  29. data/spec/examples/valid/nested-objects.toon +9 -0
  30. data/spec/examples/valid/objects.toon +6 -0
  31. data/spec/examples/valid/pipe-delimiter.toon +3 -0
  32. data/spec/examples/valid/primitive-arrays.toon +3 -0
  33. data/spec/examples/valid/tab-delimiter.toon +3 -0
  34. data/spec/fixtures/encode/arrays-nested.json +120 -0
  35. data/spec/fixtures/encode/arrays-objects.json +160 -0
  36. data/spec/fixtures/encode/arrays-primitive.json +95 -0
  37. data/spec/fixtures/encode/arrays-tabular.json +182 -0
  38. data/spec/fixtures/encode/delimiters.json +214 -0
  39. data/spec/fixtures/encode/objects-keyed.json +160 -0
  40. data/spec/fixtures/encode/objects.json +288 -0
  41. data/spec/fixtures/encode/primitives.json +262 -0
  42. data/spec/fixtures/encode/whitespace.json +54 -0
  43. 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