ruby-toon 1.0.1 → 1.0.2
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 +2 -0
- data/lib/extensions/active_support.rb +9 -0
- data/lib/extensions/core.rb +24 -0
- data/lib/toon/decoder.rb +169 -151
- data/lib/toon/encoder.rb +133 -89
- data/lib/toon/version.rb +1 -1
- data/lib/toon.rb +21 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6ebe1e066311203869e7a9c6f0d6d54e7016eddf9d8b50a47d85198ee84c458
|
|
4
|
+
data.tar.gz: bc1e3ab72636edc57c512c327c3802a14505ce968b197882dfd6e8093bca9069
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8dc9df86a1128eb5263feb7f382398671df45d0751175ba2c1b45804fb6bb9a456e04f15de59ffa7bd5536b6dd64f429a2e74aa8f554ca42f965a451bdfd68d6
|
|
7
|
+
data.tar.gz: f4b9f5594635a708a9ef96a93aeba12cd395cb276e56d77a08aadcd5f0cbef7b4eee16d5db09133b052703881349c70831d0b257826f1d26bb723d9270a8c715
|
data/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# **Toon** (Token-Oriented Object Notation for Ruby)
|
|
2
|
+
[](https://badge.fury.io/rb/ruby-toon)
|
|
3
|
+
[](https://github.com/anilyanduri/toon/actions/workflows/ruby.yml)
|
|
2
4
|
|
|
3
5
|
`toon` is a Ruby implementation of **TOON (Token-Oriented Object Notation)**
|
|
4
6
|
a compact, readable, indentation-based data format designed for humans *and* machines.
|
|
@@ -2,6 +2,9 @@ module Toon
|
|
|
2
2
|
module Extensions
|
|
3
3
|
module ActiveSupport
|
|
4
4
|
module ObjectMethods
|
|
5
|
+
# Serialize the object into TOON, preferring +as_json+ when available.
|
|
6
|
+
# @param opts [Hash] encoder options passed to {Toon.generate}.
|
|
7
|
+
# @return [String] TOON payload.
|
|
5
8
|
def to_toon(**opts)
|
|
6
9
|
payload = respond_to?(:as_json) ? as_json : self
|
|
7
10
|
Toon.generate(payload, **opts)
|
|
@@ -10,16 +13,22 @@ module Toon
|
|
|
10
13
|
|
|
11
14
|
module_function
|
|
12
15
|
|
|
16
|
+
# Inject +to_toon+ into Object so any model can be exported.
|
|
17
|
+
# @return [void]
|
|
13
18
|
def install!
|
|
14
19
|
return if Object.method_defined?(:to_toon)
|
|
15
20
|
Object.include(ObjectMethods)
|
|
16
21
|
end
|
|
17
22
|
|
|
23
|
+
# Install Object methods only when ActiveSupport is present.
|
|
24
|
+
# @return [void]
|
|
18
25
|
def ensure_installed!
|
|
19
26
|
return unless defined?(::ActiveSupport)
|
|
20
27
|
install!
|
|
21
28
|
end
|
|
22
29
|
|
|
30
|
+
# Attach a TracePoint that installs hooks once ActiveSupport loads.
|
|
31
|
+
# @return [void]
|
|
23
32
|
def watch_for_active_support!
|
|
24
33
|
return if defined?(@tracepoint) && @tracepoint&.enabled?
|
|
25
34
|
@tracepoint = TracePoint.new(:end) do |tp|
|
data/lib/extensions/core.rb
CHANGED
|
@@ -5,14 +5,26 @@ module Toon
|
|
|
5
5
|
module Core
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
|
+
# Serialize a Hash-like object into TOON using the global encoder.
|
|
9
|
+
# @param target [Hash] structure to encode.
|
|
10
|
+
# @param opts [Hash] options passed through to {Toon.generate}.
|
|
11
|
+
# @return [String] TOON string.
|
|
8
12
|
def hash_to_toon(target, **opts)
|
|
9
13
|
Toon.generate(target, **opts)
|
|
10
14
|
end
|
|
11
15
|
|
|
16
|
+
# Serialize an Array into TOON by delegating to {Toon.generate}.
|
|
17
|
+
# @param target [Array] structure to encode.
|
|
18
|
+
# @param opts [Hash] options passed through to the encoder.
|
|
19
|
+
# @return [String] TOON string.
|
|
12
20
|
def array_to_toon(target, **opts)
|
|
13
21
|
Toon.generate(target, **opts)
|
|
14
22
|
end
|
|
15
23
|
|
|
24
|
+
# Convert the object to JSON using the bundled JSON gem.
|
|
25
|
+
# @param target [Object] structure to encode.
|
|
26
|
+
# @param args [Array] options forwarded to {JSON.generate}.
|
|
27
|
+
# @return [String] JSON payload.
|
|
16
28
|
def to_json_payload(target, *args)
|
|
17
29
|
JSON.generate(target, *args)
|
|
18
30
|
end
|
|
@@ -22,12 +34,18 @@ end
|
|
|
22
34
|
|
|
23
35
|
class Hash
|
|
24
36
|
unless method_defined?(:to_toon)
|
|
37
|
+
# Serialize the hash into TOON format.
|
|
38
|
+
# @param opts [Hash] encoder options.
|
|
39
|
+
# @return [String] TOON payload.
|
|
25
40
|
def to_toon(**opts)
|
|
26
41
|
Toon::Extensions::Core.hash_to_toon(self, **opts)
|
|
27
42
|
end
|
|
28
43
|
end
|
|
29
44
|
|
|
30
45
|
unless method_defined?(:to_json)
|
|
46
|
+
# Serialize the hash into JSON via the core extension helper.
|
|
47
|
+
# @param args [Array] JSON serialization options.
|
|
48
|
+
# @return [String] JSON payload.
|
|
31
49
|
def to_json(*args)
|
|
32
50
|
Toon::Extensions::Core.to_json_payload(self, *args)
|
|
33
51
|
end
|
|
@@ -36,12 +54,18 @@ end
|
|
|
36
54
|
|
|
37
55
|
class Array
|
|
38
56
|
unless method_defined?(:to_toon)
|
|
57
|
+
# Serialize the array into TOON format.
|
|
58
|
+
# @param opts [Hash] encoder options.
|
|
59
|
+
# @return [String] TOON payload.
|
|
39
60
|
def to_toon(**opts)
|
|
40
61
|
Toon::Extensions::Core.array_to_toon(self, **opts)
|
|
41
62
|
end
|
|
42
63
|
end
|
|
43
64
|
|
|
44
65
|
unless method_defined?(:to_json)
|
|
66
|
+
# Serialize the array into JSON via the core extension helper.
|
|
67
|
+
# @param args [Array] JSON serialization options.
|
|
68
|
+
# @return [String] JSON payload.
|
|
45
69
|
def to_json(*args)
|
|
46
70
|
Toon::Extensions::Core.to_json_payload(self, *args)
|
|
47
71
|
end
|
data/lib/toon/decoder.rb
CHANGED
|
@@ -2,181 +2,199 @@ module Toon
|
|
|
2
2
|
module Decoder
|
|
3
3
|
module_function
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
# Parse a TOON string and produce the corresponding Ruby structure.
|
|
6
|
+
# @param str [String] TOON payload.
|
|
7
|
+
# @param opts [Hash] reserved for future parser options.
|
|
8
|
+
# @return [Object] reconstructed Ruby data.
|
|
9
|
+
def parse(str, **opts)
|
|
10
|
+
lines = str.gsub("\r\n", "\n").split("\n")
|
|
11
|
+
parse_lines(lines)
|
|
12
|
+
end
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
# Read all data from an IO-like object and parse it as TOON.
|
|
15
|
+
# @param io [#read] source stream.
|
|
16
|
+
# @param opts [Hash] reserved for future parser options.
|
|
17
|
+
# @return [Object] reconstructed Ruby data.
|
|
18
|
+
def load(io, **opts)
|
|
19
|
+
parse(io.read, **opts)
|
|
20
|
+
end
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
# Core parser that works on an array of sanitized TOON lines.
|
|
23
|
+
# @param lines [Array<String>] TOON lines without newlines.
|
|
24
|
+
# @return [Hash] root object constructed from the input.
|
|
25
|
+
def parse_lines(lines)
|
|
26
|
+
root = {}
|
|
27
|
+
stack = [ { indent: -1, obj: root, parent: nil, key: nil } ]
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
i = 0
|
|
30
|
+
while i < lines.length
|
|
31
|
+
raw = lines[i].rstrip
|
|
32
|
+
i += 1
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
content = raw.strip
|
|
34
|
+
next if raw.strip.empty? || raw.strip.start_with?('#')
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
stack.pop
|
|
31
|
-
end
|
|
36
|
+
indent = leading_spaces(raw)
|
|
37
|
+
content = raw.strip
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# ============================================================
|
|
37
|
-
# FLAT TABULAR ARRAY: users[2]{id,name}:
|
|
38
|
-
# ============================================================
|
|
39
|
-
if m = content.match(/^(\w+)\[(\d+)\]\{([^}]*)\}:$/)
|
|
40
|
-
key = m[1]
|
|
41
|
-
count = m[2].to_i
|
|
42
|
-
fields = m[3].split(",").map(&:strip)
|
|
43
|
-
|
|
44
|
-
rows = []
|
|
45
|
-
count.times do
|
|
46
|
-
row = lines[i]&.strip
|
|
47
|
-
i += 1
|
|
48
|
-
next unless row
|
|
49
|
-
values = split_row(row.strip)
|
|
50
|
-
rows << Hash[fields.zip(values)]
|
|
39
|
+
# Fix indentation
|
|
40
|
+
while indent <= stack.last[:indent]
|
|
41
|
+
stack.pop
|
|
51
42
|
end
|
|
52
43
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
44
|
+
current = stack.last
|
|
45
|
+
parent_obj = current[:obj]
|
|
46
|
+
|
|
47
|
+
# ============================================================
|
|
48
|
+
# FLAT TABULAR ARRAY: users[2]{id,name}:
|
|
49
|
+
# ============================================================
|
|
50
|
+
if m = content.match(/^(\w+)\[(\d+)\]\{([^}]*)\}:$/)
|
|
51
|
+
key = m[1]
|
|
52
|
+
count = m[2].to_i
|
|
53
|
+
fields = m[3].split(",").map(&:strip)
|
|
54
|
+
|
|
55
|
+
rows = []
|
|
56
|
+
count.times do
|
|
57
|
+
row = lines[i]&.strip
|
|
58
|
+
i += 1
|
|
59
|
+
next unless row
|
|
60
|
+
values = split_row(row.strip)
|
|
61
|
+
rows << Hash[fields.zip(values)]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
parent_obj[key] = rows
|
|
65
|
+
next
|
|
70
66
|
end
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
frame = stack.last
|
|
90
|
-
parent = frame[:parent]
|
|
91
|
-
key = frame[:key]
|
|
92
|
-
|
|
93
|
-
# ============================================================
|
|
94
|
-
# NESTED TABULAR ARRAY:
|
|
95
|
-
# users:
|
|
96
|
-
# [2]{id,name}:
|
|
97
|
-
# ============================================================
|
|
98
|
-
if m = content.match(/^\[(\d+)\]\{([^}]*)\}:$/)
|
|
99
|
-
count = m[1].to_i
|
|
100
|
-
fields = m[2].split(',').map(&:strip)
|
|
101
|
-
|
|
102
|
-
rows = []
|
|
103
|
-
count.times do
|
|
104
|
-
row = lines[i]&.strip
|
|
105
|
-
i += 1
|
|
106
|
-
next unless row
|
|
107
|
-
values = split_row(row).map { |v| parse_scalar(v) }
|
|
108
|
-
rows << Hash[fields.zip(values)]
|
|
68
|
+
# ============================================================
|
|
69
|
+
# FLAT PRIMITIVE ARRAY: colors[3]:
|
|
70
|
+
# ============================================================
|
|
71
|
+
if m = content.match(/^(\w+)\[(\d+)\]:$/)
|
|
72
|
+
key = m[1]
|
|
73
|
+
count = m[2].to_i
|
|
74
|
+
|
|
75
|
+
values = []
|
|
76
|
+
count.times do
|
|
77
|
+
row = lines[i]&.strip
|
|
78
|
+
i += 1
|
|
79
|
+
next unless row
|
|
80
|
+
values << parse_scalar(row)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
parent_obj[key] = values
|
|
84
|
+
next
|
|
109
85
|
end
|
|
110
86
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
87
|
+
# ============================================================
|
|
88
|
+
# NESTED OBJECT KEY: key:
|
|
89
|
+
# ============================================================
|
|
90
|
+
if m = content.match(/^(\w+):$/)
|
|
91
|
+
key = m[1]
|
|
92
|
+
new_obj = {}
|
|
115
93
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# ============================================================
|
|
121
|
-
if m = content.match(/^\[(\d+)\]:$/)
|
|
122
|
-
count = m[1].to_i
|
|
94
|
+
parent_obj[key] = new_obj
|
|
95
|
+
stack << { indent: indent, obj: new_obj, parent: parent_obj, key: key }
|
|
96
|
+
next
|
|
97
|
+
end
|
|
123
98
|
|
|
124
|
-
|
|
125
|
-
|
|
99
|
+
# Refresh frame for nested array parsing
|
|
100
|
+
frame = stack.last
|
|
101
|
+
parent = frame[:parent]
|
|
102
|
+
key = frame[:key]
|
|
103
|
+
|
|
104
|
+
# ============================================================
|
|
105
|
+
# NESTED TABULAR ARRAY:
|
|
106
|
+
# users:
|
|
107
|
+
# [2]{id,name}:
|
|
108
|
+
# ============================================================
|
|
109
|
+
if m = content.match(/^\[(\d+)\]\{([^}]*)\}:$/)
|
|
110
|
+
count = m[1].to_i
|
|
111
|
+
fields = m[2].split(',').map(&:strip)
|
|
112
|
+
|
|
113
|
+
rows = []
|
|
114
|
+
count.times do
|
|
115
|
+
row = lines[i]&.strip
|
|
116
|
+
i += 1
|
|
117
|
+
next unless row
|
|
118
|
+
values = split_row(row).map { |v| parse_scalar(v) }
|
|
119
|
+
rows << Hash[fields.zip(values)]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
parent[key] = rows
|
|
123
|
+
stack.pop
|
|
124
|
+
next
|
|
126
125
|
end
|
|
127
126
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
# ============================================================
|
|
128
|
+
# NESTED PRIMITIVE ARRAY:
|
|
129
|
+
# colors:
|
|
130
|
+
# [3]:
|
|
131
|
+
# ============================================================
|
|
132
|
+
if m = content.match(/^\[(\d+)\]:$/)
|
|
133
|
+
count = m[1].to_i
|
|
134
|
+
|
|
135
|
+
if key.nil?
|
|
136
|
+
raise Toon::Error, "Malformed TOON: array header '#{content}' must be under a key (e.g., 'colors:')"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
values = []
|
|
140
|
+
count.times do
|
|
141
|
+
row = lines[i]&.strip
|
|
142
|
+
i += 1
|
|
143
|
+
next unless row
|
|
144
|
+
values << parse_scalar(row)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
parent[key] = values
|
|
148
|
+
stack.pop
|
|
149
|
+
next
|
|
134
150
|
end
|
|
135
151
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
152
|
+
# ============================================================
|
|
153
|
+
# SIMPLE KEY: VALUE
|
|
154
|
+
# ============================================================
|
|
155
|
+
if m = content.match(/^(\w+):\s*(.*)$/)
|
|
156
|
+
k = m[1]
|
|
157
|
+
v = parse_scalar(m[2])
|
|
158
|
+
parent_obj[k] = v
|
|
159
|
+
next
|
|
160
|
+
end
|
|
139
161
|
end
|
|
140
162
|
|
|
141
|
-
|
|
142
|
-
# SIMPLE KEY: VALUE
|
|
143
|
-
# ============================================================
|
|
144
|
-
if m = content.match(/^(\w+):\s*(.*)$/)
|
|
145
|
-
k = m[1]
|
|
146
|
-
v = parse_scalar(m[2])
|
|
147
|
-
parent_obj[k] = v
|
|
148
|
-
next
|
|
149
|
-
end
|
|
163
|
+
root
|
|
150
164
|
end
|
|
151
165
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
line[/^ */].length
|
|
159
|
-
end
|
|
166
|
+
# Count the indentation depth (in spaces) at the beginning of +line+.
|
|
167
|
+
# @param line [String] line from the TOON payload.
|
|
168
|
+
# @return [Integer] number of leading spaces.
|
|
169
|
+
def leading_spaces(line)
|
|
170
|
+
line[/^ */].length
|
|
171
|
+
end
|
|
160
172
|
|
|
161
|
-
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
str
|
|
168
|
-
|
|
169
|
-
str.
|
|
170
|
-
|
|
171
|
-
str
|
|
172
|
-
|
|
173
|
-
str
|
|
173
|
+
# Convert a scalar token into the appropriate Ruby object.
|
|
174
|
+
# @param str [String] textual token.
|
|
175
|
+
# @return [Object] decoded scalar (String, Numeric, true/false, nil).
|
|
176
|
+
def parse_scalar(str)
|
|
177
|
+
# strip quotes
|
|
178
|
+
if str == 'null' then nil
|
|
179
|
+
elsif str == 'true' then true
|
|
180
|
+
elsif str == 'false' then false
|
|
181
|
+
elsif str.match?(/^".*"$/)
|
|
182
|
+
str[1..-2].gsub('\\"','"')
|
|
183
|
+
elsif str =~ /^-?\d+$/
|
|
184
|
+
str.to_i
|
|
185
|
+
elsif str =~ /^-?\d+\.\d+$/
|
|
186
|
+
str.to_f
|
|
187
|
+
else
|
|
188
|
+
str
|
|
189
|
+
end
|
|
174
190
|
end
|
|
175
|
-
end
|
|
176
191
|
|
|
177
|
-
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
192
|
+
# Split a comma-delimited row while preserving quoted delimiters.
|
|
193
|
+
# @param row [String] raw row contents.
|
|
194
|
+
# @return [Array<String>] tokenized column values.
|
|
195
|
+
def split_row(row)
|
|
196
|
+
# simplistic split on comma that ignores quoted commas - naive
|
|
197
|
+
row.scan(/(?:\"([^\"]*)\"|([^,]+))(?:,|$)/).map { |m| (m[0] || m[1]).to_s.strip }
|
|
198
|
+
end
|
|
181
199
|
end
|
|
182
200
|
end
|
data/lib/toon/encoder.rb
CHANGED
|
@@ -10,117 +10,161 @@ module Toon
|
|
|
10
10
|
pretty: false
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
# Generate a TOON string for +obj+ with the desired encoder options.
|
|
14
|
+
# @param obj [Object] structure to serialize.
|
|
15
|
+
# @param opts [Hash] overrides for {DEFAULT_OPTIONS}.
|
|
16
|
+
# @return [String] TOON payload.
|
|
17
|
+
def generate(obj, **opts)
|
|
18
|
+
options = DEFAULT_OPTIONS.merge(opts)
|
|
19
|
+
io = StringIO.new
|
|
20
|
+
write_object(io, obj, 0, options)
|
|
21
|
+
io.string
|
|
22
|
+
end
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
# Generate a human-friendly TOON payload irrespective of caller options.
|
|
25
|
+
# @param obj [Object] structure to serialize.
|
|
26
|
+
# @param opts [Hash] encoder overrides.
|
|
27
|
+
# @return [String] prettified TOON payload.
|
|
28
|
+
def pretty_generate(obj, **opts)
|
|
29
|
+
generate(obj, **opts.merge(pretty: true))
|
|
30
|
+
end
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
io
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
# Stream the serialized payload directly to an IO target.
|
|
33
|
+
# @param obj [Object] data to encode.
|
|
34
|
+
# @param io [#write] destination stream (defaults to STDOUT).
|
|
35
|
+
# @param opts [Hash] encoder overrides.
|
|
36
|
+
# @return [nil]
|
|
37
|
+
def dump(obj, io = STDOUT, **opts)
|
|
38
|
+
str = generate(obj, **opts)
|
|
39
|
+
io.write(str)
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
29
42
|
|
|
30
|
-
|
|
43
|
+
private
|
|
31
44
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
# Dispatch encoding logic for hashes, arrays, or scalar values.
|
|
46
|
+
# @param io [#write] accumulating stream.
|
|
47
|
+
# @param obj [Object] value to encode.
|
|
48
|
+
# @param level [Integer] depth in the output tree.
|
|
49
|
+
# @param options [Hash] encoder configuration.
|
|
50
|
+
def write_object(io, obj, level, options)
|
|
51
|
+
case obj
|
|
52
|
+
when Hash
|
|
53
|
+
obj.each do |k, v|
|
|
54
|
+
write_key(io, k, level)
|
|
55
|
+
if simple_value?(v)
|
|
56
|
+
io.write(format_simple(v))
|
|
57
|
+
io.write("\n")
|
|
58
|
+
else
|
|
59
|
+
io.write("\n")
|
|
60
|
+
write_object(io, v, level + 1, options)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
when Array
|
|
64
|
+
# Try to detect uniform objects for tabular style
|
|
65
|
+
if options[:compact_arrays] && array_uniform_hashes?(obj)
|
|
66
|
+
write_tabular(io, obj, level, options)
|
|
40
67
|
else
|
|
41
|
-
io
|
|
42
|
-
write_object(io, v, level + 1, options)
|
|
68
|
+
write_list(io, obj, level, options)
|
|
43
69
|
end
|
|
44
|
-
end
|
|
45
|
-
when Array
|
|
46
|
-
# Try to detect uniform objects for tabular style
|
|
47
|
-
if options[:compact_arrays] && array_uniform_hashes?(obj)
|
|
48
|
-
write_tabular(io, obj, level, options)
|
|
49
70
|
else
|
|
50
|
-
|
|
71
|
+
# scalar
|
|
72
|
+
io.write(indent(level))
|
|
73
|
+
io.write(format_simple(obj))
|
|
74
|
+
io.write("\n")
|
|
51
75
|
end
|
|
52
|
-
else
|
|
53
|
-
# scalar
|
|
54
|
-
io.write(indent(level))
|
|
55
|
-
io.write(format_simple(obj))
|
|
56
|
-
io.write("\n")
|
|
57
76
|
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def write_key(io, key, level)
|
|
61
|
-
io.write(indent(level))
|
|
62
|
-
io.write("#{key}:")
|
|
63
|
-
end
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
io.write(indent(level
|
|
71
|
-
|
|
72
|
-
io.write(vals.join(options[:delimiter]))
|
|
73
|
-
io.write("\n")
|
|
78
|
+
# Emit a hash key label aligned to the requested indentation depth.
|
|
79
|
+
# @param io [#write] accumulating stream.
|
|
80
|
+
# @param key [String, Symbol] key to render.
|
|
81
|
+
# @param level [Integer] indentation level.
|
|
82
|
+
def write_key(io, key, level)
|
|
83
|
+
io.write(indent(level))
|
|
84
|
+
io.write("#{key}:")
|
|
74
85
|
end
|
|
75
|
-
end
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
io.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
# Render an array of hashes using the compact tabular notation.
|
|
88
|
+
# @param io [#write] accumulating stream.
|
|
89
|
+
# @param arr [Array<Hash>] rows to encode.
|
|
90
|
+
# @param level [Integer] indentation level.
|
|
91
|
+
# @param options [Hash] encoder configuration.
|
|
92
|
+
def write_tabular(io, arr, level, options)
|
|
93
|
+
keys = (arr.map(&:keys).reduce(:|) || []).uniq
|
|
94
|
+
io.write(indent(level))
|
|
95
|
+
io.write("[#{arr.length}]{#{keys.join(',')}}:\n")
|
|
96
|
+
arr.each do |row|
|
|
97
|
+
io.write(indent(level + 1))
|
|
98
|
+
vals = keys.map { |k| format_simple(row[k]) }
|
|
99
|
+
io.write(vals.join(options[:delimiter]))
|
|
84
100
|
io.write("\n")
|
|
85
|
-
else
|
|
86
|
-
# nested object or array
|
|
87
|
-
write_object(io, el, level + 1, options)
|
|
88
101
|
end
|
|
89
102
|
end
|
|
90
|
-
end
|
|
91
103
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
# Render a heterogeneous array in the multi-line list form.
|
|
105
|
+
# @param io [#write] accumulating stream.
|
|
106
|
+
# @param arr [Array] values to encode.
|
|
107
|
+
# @param level [Integer] indentation level.
|
|
108
|
+
# @param options [Hash] encoder configuration.
|
|
109
|
+
def write_list(io, arr, level, options)
|
|
110
|
+
io.write(indent(level))
|
|
111
|
+
io.write("[#{arr.length}]:\n")
|
|
112
|
+
arr.each do |el|
|
|
113
|
+
io.write(indent(level + 1))
|
|
114
|
+
if simple_value?(el)
|
|
115
|
+
io.write(format_simple(el))
|
|
116
|
+
io.write("\n")
|
|
117
|
+
else
|
|
118
|
+
# nested object or array
|
|
119
|
+
write_object(io, el, level + 1, options)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
95
123
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
# Produce indentation padding for the supplied level.
|
|
125
|
+
# @param level [Integer] nesting depth.
|
|
126
|
+
# @return [String] spaces for indent.
|
|
127
|
+
def indent(level)
|
|
128
|
+
' ' * (level * 2)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Identify whether +v+ is a scalar that can be inlined.
|
|
132
|
+
# @param v [Object] value to test.
|
|
133
|
+
# @return [Boolean] true if +v+ is nil, numeric, boolean, or string.
|
|
134
|
+
def simple_value?(v)
|
|
135
|
+
v.nil? || v.is_a?(Numeric) || v.is_a?(String) || v == true || v == false
|
|
136
|
+
end
|
|
99
137
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
138
|
+
# Convert a scalar value into its TOON string representation.
|
|
139
|
+
# @param v [Object] scalar value.
|
|
140
|
+
# @return [String] formatted representation.
|
|
141
|
+
def format_simple(v)
|
|
142
|
+
case v
|
|
143
|
+
when String
|
|
144
|
+
# naive quoting: quote when contains delimiter or colon or newline
|
|
145
|
+
if v.empty? || v.match(/[,:\n{}\[\]]/)
|
|
146
|
+
'"' + v.gsub('"', '\\"') + '"'
|
|
147
|
+
else
|
|
148
|
+
v
|
|
149
|
+
end
|
|
150
|
+
when nil
|
|
151
|
+
'null'
|
|
152
|
+
when true
|
|
153
|
+
'true'
|
|
154
|
+
when false
|
|
155
|
+
'false'
|
|
106
156
|
else
|
|
107
|
-
v
|
|
157
|
+
v.to_s
|
|
108
158
|
end
|
|
109
|
-
when nil
|
|
110
|
-
'null'
|
|
111
|
-
when true
|
|
112
|
-
'true'
|
|
113
|
-
when false
|
|
114
|
-
'false'
|
|
115
|
-
else
|
|
116
|
-
v.to_s
|
|
117
159
|
end
|
|
118
|
-
end
|
|
119
160
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
161
|
+
# Check whether +arr+ contains only hashes sharing the same keys.
|
|
162
|
+
# @param arr [Array] array to test.
|
|
163
|
+
# @return [Boolean] true when the compact tabular format applies.
|
|
164
|
+
def array_uniform_hashes?(arr)
|
|
165
|
+
return false if arr.empty?
|
|
166
|
+
arr.all? { |e| e.is_a?(Hash) } && (arr.map { |h| h.keys.sort }.uniq.length == 1)
|
|
167
|
+
end
|
|
124
168
|
|
|
125
169
|
module_function :write_object, :write_key, :write_tabular, :write_list,
|
|
126
170
|
:indent, :simple_value?, :format_simple, :array_uniform_hashes?
|
data/lib/toon/version.rb
CHANGED
data/lib/toon.rb
CHANGED
|
@@ -7,22 +7,43 @@ require_relative "extensions/active_support"
|
|
|
7
7
|
module Toon
|
|
8
8
|
class Error < StandardError; end
|
|
9
9
|
|
|
10
|
+
# Generate a TOON-formatted String from any serializable Ruby object.
|
|
11
|
+
# @param obj [Object] the object to encode.
|
|
12
|
+
# @param opts [Hash] encoder options forwarded to `Toon::Encoder`.
|
|
13
|
+
# @return [String] the TOON payload.
|
|
10
14
|
def self.generate(obj, **opts)
|
|
11
15
|
Toon::Encoder.generate(obj, **opts)
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# Generate a human-friendly TOON String with extra spacing and indentation.
|
|
19
|
+
# @param obj [Object] the object to encode.
|
|
20
|
+
# @param opts [Hash] encoder options forwarded to `Toon::Encoder`.
|
|
21
|
+
# @return [String] the prettified TOON payload.
|
|
14
22
|
def self.pretty_generate(obj, **opts)
|
|
15
23
|
Toon::Encoder.pretty_generate(obj, **opts)
|
|
16
24
|
end
|
|
17
25
|
|
|
26
|
+
# Parse a TOON String and reconstruct the Ruby data structure.
|
|
27
|
+
# @param str [String] the TOON representation.
|
|
28
|
+
# @param opts [Hash] decoder options forwarded to `Toon::Decoder`.
|
|
29
|
+
# @return [Object] the decoded Ruby structure.
|
|
18
30
|
def self.parse(str, **opts)
|
|
19
31
|
Toon::Decoder.parse(str, **opts)
|
|
20
32
|
end
|
|
21
33
|
|
|
34
|
+
# Read a TOON payload from any IO-like object and parse it.
|
|
35
|
+
# @param io [#read] an IO that responds to `#read`.
|
|
36
|
+
# @param opts [Hash] decoder options forwarded to `Toon::Decoder`.
|
|
37
|
+
# @return [Object] the decoded Ruby structure.
|
|
22
38
|
def self.load(io, **opts)
|
|
23
39
|
Toon::Decoder.load(io, **opts)
|
|
24
40
|
end
|
|
25
41
|
|
|
42
|
+
# Stream a TOON payload for +obj+ into the provided IO target.
|
|
43
|
+
# @param obj [Object] the object to encode.
|
|
44
|
+
# @param io [#write] where the payload should be written.
|
|
45
|
+
# @param opts [Hash] encoder options forwarded to `Toon::Encoder`.
|
|
46
|
+
# @return [nil]
|
|
26
47
|
def self.dump(obj, io = STDOUT, **opts)
|
|
27
48
|
Toon::Encoder.dump(obj, io, **opts)
|
|
28
49
|
end
|