ruby-json-toon 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +98 -0
- data/lib/json_to_toon/encoder.rb +399 -0
- data/lib/json_to_toon/version.rb +5 -0
- data/lib/json_to_toon.rb +37 -0
- metadata +168 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2eda6d64edfc1cc2a7f956141060c3705bf4a66e668ce10622f42c30ba2ff92b
|
|
4
|
+
data.tar.gz: f8dc21d8f08cc1751463d3b664c00c27f5a83720b715d0894be5e71c29ea0a12
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 435a729daee86264c5eab4917f6191fe0fc70646c7c49574a85a74c34b19ecebd58fd1e04fff136a36667346a2aa511f3a81c8f8732c4bcf6621ae2ccbc6ffd7
|
|
7
|
+
data.tar.gz: 00ae26b0633543fb121c7910efe3ab5d79b699d1143bd963e659fc2b3f94f8cbc9a1278ca81bbb65343476a05a44fb8d216aedd892ed108998451c9491d729a1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial implementation of JSON to TOON converter
|
|
12
|
+
- Support for all TOON format types (objects, arrays, primitives)
|
|
13
|
+
- Configurable delimiters (comma, tab, pipe)
|
|
14
|
+
- Configurable indentation
|
|
15
|
+
- Optional length markers
|
|
16
|
+
- Comprehensive test suite
|
|
17
|
+
- Full TOON specification compliance
|
|
18
|
+
|
|
19
|
+
## [0.1.0] - Initial publish
|
|
20
|
+
### Added
|
|
21
|
+
- Initial Encoder class implementation
|
|
22
|
+
- Tests for JSON to TOON conversion
|
|
23
|
+
|
|
24
|
+
##[0.2.0] - First tagged release
|
|
25
|
+
### Added
|
|
26
|
+
- Updated to run release workflow on tag pushes
|
|
27
|
+
|
|
28
|
+
## [1.0.0] - TBD
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Initial release
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 [Jitendra Neema]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# JSON to TOON
|
|
2
|
+
|
|
3
|
+
A lightweight, zero-dependency Ruby library for converting JSON data to TOON (Token-Oriented Object Notation) format, achieving 30-60% token reduction for LLM applications.
|
|
4
|
+
|
|
5
|
+
## What is TOON?
|
|
6
|
+
|
|
7
|
+
TOON (Token-Oriented Object Notation) is a compact, indentation-based data format optimized for LLM token efficiency. It uses 30-60% fewer tokens than JSON while remaining human-readable.
|
|
8
|
+
|
|
9
|
+
### Comparison
|
|
10
|
+
|
|
11
|
+
**JSON (87 tokens):**
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"users": [
|
|
15
|
+
{"id": 1, "name": "Alice", "role": "admin"},
|
|
16
|
+
{"id": 2, "name": "Bob", "role": "user"}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**TOON (31 tokens):**
|
|
22
|
+
```
|
|
23
|
+
users[2]{id,name,role}:
|
|
24
|
+
1,Alice,admin
|
|
25
|
+
2,Bob,user
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Add to your Gemfile:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
gem 'json_to_toon'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or install directly:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
gem install json_to_toon
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
require 'json_to_toon'
|
|
46
|
+
|
|
47
|
+
# Convert Ruby hash to TOON
|
|
48
|
+
data = { name: 'Ada', role: 'admin', active: true }
|
|
49
|
+
toon = JsonToToon.encode(data)
|
|
50
|
+
# Output:
|
|
51
|
+
# name: Ada
|
|
52
|
+
# role: admin
|
|
53
|
+
# active: true
|
|
54
|
+
|
|
55
|
+
# Convert JSON string to TOON
|
|
56
|
+
json_data = JSON.parse('{"users":[{"id":1,"name":"Alice"}]}')
|
|
57
|
+
toon = JsonToToon.encode(json_data)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Documentation
|
|
61
|
+
|
|
62
|
+
See full documentation at [rubydoc.info](https://rubydoc.info/gems/json_to_toon)
|
|
63
|
+
|
|
64
|
+
## Options
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
JsonToToon.encode(data,
|
|
68
|
+
indent: 2, # Spaces per indentation level (default: 2)
|
|
69
|
+
delimiter: ',', # Delimiter: ',' (default), "\t", or '|'
|
|
70
|
+
length_marker: '#' # Length marker or false (default: false)
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Install dependencies
|
|
78
|
+
bundle install
|
|
79
|
+
|
|
80
|
+
# Run tests
|
|
81
|
+
bundle exec rspec
|
|
82
|
+
|
|
83
|
+
# Run linter
|
|
84
|
+
bundle exec rubocop
|
|
85
|
+
|
|
86
|
+
# Build gem
|
|
87
|
+
gem build json_to_toon.gemspec
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT License - see LICENSE file for details
|
|
93
|
+
|
|
94
|
+
## Links
|
|
95
|
+
|
|
96
|
+
- [TOON Specification](https://toonformat.dev)
|
|
97
|
+
- [GitHub Repository](https://github.com/jitendra-neema/json_to_toon)
|
|
98
|
+
- [Bug Tracker](https://github.com/jitendra-neema/json_to_toon/issues)
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bigdecimal'
|
|
4
|
+
|
|
5
|
+
module JsonToToon
|
|
6
|
+
class Encoder
|
|
7
|
+
DEFAULT_INDENT = 2
|
|
8
|
+
DELIMITER_COMMA = ','
|
|
9
|
+
DELIMITER_TAB = "\t"
|
|
10
|
+
DELIMITER_PIPE = '|'
|
|
11
|
+
VALID_DELIMITERS = [DELIMITER_COMMA, DELIMITER_TAB, DELIMITER_PIPE].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :indent_size, :delimiter, :length_marker
|
|
14
|
+
|
|
15
|
+
def initialize(options = {})
|
|
16
|
+
@indent_size = options[:indent] || DEFAULT_INDENT
|
|
17
|
+
@delimiter = options[:delimiter] || DELIMITER_COMMA
|
|
18
|
+
@length_marker = options[:length_marker] || false
|
|
19
|
+
|
|
20
|
+
validate_options!
|
|
21
|
+
|
|
22
|
+
@output = []
|
|
23
|
+
@visited = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def encode(value)
|
|
27
|
+
@output = []
|
|
28
|
+
@visited = {}
|
|
29
|
+
result = encode_value(value, 0)
|
|
30
|
+
|
|
31
|
+
# If encoding a primitive value at root, encode_value returns the
|
|
32
|
+
# formatted string but nothing is emitted to @output. In that case
|
|
33
|
+
# return the direct result.
|
|
34
|
+
return result.to_s if @output.empty? && result
|
|
35
|
+
|
|
36
|
+
@output.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delimiter_marker
|
|
40
|
+
case @delimiter
|
|
41
|
+
when DELIMITER_COMMA then ''
|
|
42
|
+
when DELIMITER_TAB then "\t"
|
|
43
|
+
when DELIMITER_PIPE then '|'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def length_prefix
|
|
48
|
+
@length_marker == '#' ? '#' : ''
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_options!
|
|
54
|
+
unless @indent_size.is_a?(Integer) && @indent_size.positive?
|
|
55
|
+
raise InvalidOptionError, "indent must be a positive integer, got: #{@indent_size.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
unless VALID_DELIMITERS.include?(@delimiter)
|
|
59
|
+
raise InvalidOptionError,
|
|
60
|
+
"delimiter must be one of #{VALID_DELIMITERS.map(&:inspect).join(', ')}, got: #{@delimiter.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return if @length_marker == '#' || @length_marker == false
|
|
64
|
+
|
|
65
|
+
raise InvalidOptionError, "length_marker must be '#' or false, got: #{@length_marker.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def encode_value(value, depth, key = nil)
|
|
69
|
+
check_circular_reference(value)
|
|
70
|
+
|
|
71
|
+
result = case value
|
|
72
|
+
when Hash then encode_hash(value, depth)
|
|
73
|
+
when Array then encode_array_with_key(value, depth, key)
|
|
74
|
+
else format_value(value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unmark_visited(value)
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def check_circular_reference(value)
|
|
82
|
+
return unless value.is_a?(Hash) || value.is_a?(Array)
|
|
83
|
+
|
|
84
|
+
object_id = value.object_id
|
|
85
|
+
raise CircularReferenceError, 'Circular reference detected' if @visited[object_id]
|
|
86
|
+
|
|
87
|
+
@visited[object_id] = true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def unmark_visited(value)
|
|
91
|
+
@visited.delete(value.object_id) if value.is_a?(Hash) || value.is_a?(Array)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def encode_hash(hash, depth)
|
|
95
|
+
return if hash.empty? && depth.zero?
|
|
96
|
+
|
|
97
|
+
hash.each do |key, value|
|
|
98
|
+
formatted_key = format_key(key)
|
|
99
|
+
|
|
100
|
+
if primitive?(value)
|
|
101
|
+
formatted_value = format_value(value)
|
|
102
|
+
emit_line(indent(depth) + "#{formatted_key}: #{formatted_value}")
|
|
103
|
+
elsif value.is_a?(Hash)
|
|
104
|
+
emit_line(indent(depth) + "#{formatted_key}:")
|
|
105
|
+
encode_value(value, depth + 1) unless value.empty?
|
|
106
|
+
elsif value.is_a?(Array)
|
|
107
|
+
encode_value(value, depth, formatted_key)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def encode_array_with_key(array, depth, key)
|
|
113
|
+
if array.empty?
|
|
114
|
+
header = key ? "#{key}[#{length_prefix}0]:" : "[#{length_prefix}0]:"
|
|
115
|
+
emit_line(indent(depth) + header)
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if tabular_eligible?(array)
|
|
120
|
+
encode_tabular(array, depth, key)
|
|
121
|
+
elsif all_primitives?(array)
|
|
122
|
+
encode_inline(array, depth, key)
|
|
123
|
+
else
|
|
124
|
+
encode_list(array, depth, key)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# FIXED: Tabular eligibility with proper key checking
|
|
129
|
+
def tabular_eligible?(array)
|
|
130
|
+
return false if array.empty?
|
|
131
|
+
return false unless array.all? { |item| item.is_a?(Hash) && !item.empty? }
|
|
132
|
+
|
|
133
|
+
# Get first object's key set (sorted for comparison)
|
|
134
|
+
first_keys = array.first.keys.sort
|
|
135
|
+
|
|
136
|
+
# All objects must have exactly the same keys
|
|
137
|
+
return false unless array.all? { |item| item.keys.sort == first_keys }
|
|
138
|
+
|
|
139
|
+
# All values must be primitives
|
|
140
|
+
array.all? { |item| item.values.all? { |v| primitive?(v) } }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def all_primitives?(array)
|
|
144
|
+
array.all? { |item| primitive?(item) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# FIXED: Tabular encoding with correct field order from first object
|
|
148
|
+
def encode_tabular(array, depth, key)
|
|
149
|
+
# Use first object's key order for header
|
|
150
|
+
field_order = array.first.keys
|
|
151
|
+
field_header = field_order.map { |k| format_key(k) }.join(delimiter)
|
|
152
|
+
|
|
153
|
+
length_str = "#{length_prefix}#{array.length}"
|
|
154
|
+
marker = delimiter_marker
|
|
155
|
+
header = if key
|
|
156
|
+
"#{key}[#{length_str}#{marker}]{#{field_header}}:"
|
|
157
|
+
else
|
|
158
|
+
"[#{length_str}#{marker}]{#{field_header}}:"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
emit_line(indent(depth) + header)
|
|
162
|
+
|
|
163
|
+
array.each do |item|
|
|
164
|
+
# CRITICAL: Use field_order, not item's key order!
|
|
165
|
+
values = field_order.map { |k| format_value(item[k]) }
|
|
166
|
+
row = values.join(@delimiter)
|
|
167
|
+
emit_line(indent(depth + 1) + row)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def encode_inline(array, depth, key)
|
|
172
|
+
length_str = "#{length_prefix}#{array.length}"
|
|
173
|
+
marker = delimiter_marker
|
|
174
|
+
header = key ? "#{key}[#{length_str}#{marker}]:" : "[#{length_str}#{marker}]:"
|
|
175
|
+
|
|
176
|
+
values = array.map { |item| format_value(item) }
|
|
177
|
+
line = "#{indent(depth)}#{header} #{values.join(@delimiter)}"
|
|
178
|
+
|
|
179
|
+
emit_line(line)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# FIXED: List encoding with proper indentation
|
|
183
|
+
def encode_list(array, depth, key)
|
|
184
|
+
length_str = "#{length_prefix}#{array.length}"
|
|
185
|
+
header = key ? "#{key}[#{length_str}]:" : "[#{length_str}]:"
|
|
186
|
+
emit_line(indent(depth) + header)
|
|
187
|
+
|
|
188
|
+
array.each do |item|
|
|
189
|
+
encode_list_item(item, depth + 1)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# FIXED: Proper list item indentation
|
|
194
|
+
def encode_list_item(item, depth)
|
|
195
|
+
base_indent = indent(depth)
|
|
196
|
+
hyphen_line = "#{base_indent}- "
|
|
197
|
+
field_indent = "#{base_indent} "
|
|
198
|
+
|
|
199
|
+
case item
|
|
200
|
+
when Hash
|
|
201
|
+
if item.empty?
|
|
202
|
+
emit_line(hyphen_line)
|
|
203
|
+
else
|
|
204
|
+
keys = item.keys
|
|
205
|
+
first_key = keys.first
|
|
206
|
+
formatted_key = format_key(first_key)
|
|
207
|
+
|
|
208
|
+
# Check if first value is an array (special case)
|
|
209
|
+
if item[first_key].is_a?(Array)
|
|
210
|
+
# TODO: Handle nested array as first field
|
|
211
|
+
# This is complex - needs special handling
|
|
212
|
+
encode_list_item_with_array_first(item, depth)
|
|
213
|
+
elsif item[first_key].is_a?(Hash)
|
|
214
|
+
# First field is nested object
|
|
215
|
+
emit_line("#{hyphen_line}#{formatted_key}:")
|
|
216
|
+
encode_value(item[first_key], depth + 1)
|
|
217
|
+
|
|
218
|
+
# Remaining fields
|
|
219
|
+
keys[1..].each do |k|
|
|
220
|
+
fk = format_key(k)
|
|
221
|
+
fv = format_value(item[k])
|
|
222
|
+
emit_line("#{field_indent}#{fk}: #{fv}")
|
|
223
|
+
end
|
|
224
|
+
else
|
|
225
|
+
# First field is primitive
|
|
226
|
+
fv = format_value(item[first_key])
|
|
227
|
+
emit_line("#{hyphen_line}#{formatted_key}: #{fv}")
|
|
228
|
+
|
|
229
|
+
# Remaining fields at same indent level
|
|
230
|
+
keys[1..].each do |k|
|
|
231
|
+
fk = format_key(k)
|
|
232
|
+
v = item[k]
|
|
233
|
+
|
|
234
|
+
if v.is_a?(Hash)
|
|
235
|
+
emit_line("#{field_indent}#{fk}:")
|
|
236
|
+
encode_value(v, depth + 2)
|
|
237
|
+
elsif v.is_a?(Array)
|
|
238
|
+
encode_value(v, depth + 1, fk)
|
|
239
|
+
else
|
|
240
|
+
fv = format_value(v)
|
|
241
|
+
emit_line("#{field_indent}#{fk}: #{fv}")
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
when Array
|
|
247
|
+
# Nested array in list - go through encode_value for circular checks
|
|
248
|
+
encode_value(item, depth, nil)
|
|
249
|
+
else
|
|
250
|
+
# Primitive
|
|
251
|
+
emit_line("#{hyphen_line}#{format_value(item)}")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# COMPLEX: Handle list item where first field is an array
|
|
256
|
+
def encode_list_item_with_array_first(item, depth)
|
|
257
|
+
# This is a complex edge case from the spec
|
|
258
|
+
# TODO: Implement proper handling per spec
|
|
259
|
+
keys = item.keys
|
|
260
|
+
first_key = keys.first
|
|
261
|
+
|
|
262
|
+
base_indent = indent(depth)
|
|
263
|
+
hyphen_line = "#{base_indent}- "
|
|
264
|
+
field_indent = "#{base_indent} "
|
|
265
|
+
|
|
266
|
+
formatted_key = format_key(first_key)
|
|
267
|
+
emit_line("#{hyphen_line}#{formatted_key}:")
|
|
268
|
+
encode_value(item[first_key], depth + 1, nil)
|
|
269
|
+
|
|
270
|
+
keys[1..].each do |k|
|
|
271
|
+
fk = format_key(k)
|
|
272
|
+
fv = format_value(item[k])
|
|
273
|
+
emit_line("#{field_indent}#{fk}: #{fv}")
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def primitive?(value)
|
|
278
|
+
value.nil? ||
|
|
279
|
+
value.is_a?(String) ||
|
|
280
|
+
value.is_a?(Numeric) ||
|
|
281
|
+
value == true ||
|
|
282
|
+
value == false ||
|
|
283
|
+
value.is_a?(Symbol)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def format_key(key)
|
|
287
|
+
key_str = key.to_s
|
|
288
|
+
needs_key_quotes?(key_str) ? quote_string(key_str) : key_str
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def needs_key_quotes?(str)
|
|
292
|
+
return true if str.empty?
|
|
293
|
+
return true if str.start_with?('-')
|
|
294
|
+
return true if str.match?(/\A\d+\z/)
|
|
295
|
+
return true if str.match?(/[\s,:"\[\]{}\\]/)
|
|
296
|
+
return true if str.match?(/[\n\r\t]/)
|
|
297
|
+
|
|
298
|
+
false
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def format_value(value)
|
|
302
|
+
case value
|
|
303
|
+
when nil then 'null'
|
|
304
|
+
when true then 'true'
|
|
305
|
+
when false then 'false'
|
|
306
|
+
when Integer then value.to_s
|
|
307
|
+
when Float then format_float(value)
|
|
308
|
+
when String then format_string(value)
|
|
309
|
+
when Symbol then format_string(value.to_s)
|
|
310
|
+
when Time, DateTime then quote_string(value.iso8601(3))
|
|
311
|
+
else 'null'
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# FIXED: Better float formatting
|
|
316
|
+
def format_float(float)
|
|
317
|
+
return 'null' if float.nan? || float.infinite?
|
|
318
|
+
return '0' if float.zero?
|
|
319
|
+
|
|
320
|
+
# Handle scientific notation and very small/large numbers
|
|
321
|
+
str = if float.abs < 1e-10 || float.abs >= 1e15
|
|
322
|
+
BigDecimal(float, 10).to_s('F')
|
|
323
|
+
else
|
|
324
|
+
float.to_s
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Remove scientific notation if present
|
|
328
|
+
str = BigDecimal(str).to_s('F') if str.include?('e') || str.include?('E')
|
|
329
|
+
|
|
330
|
+
# Remove trailing zeros after decimal point
|
|
331
|
+
str = str.sub(/\.?0+\z/, '') if str.include?('.')
|
|
332
|
+
|
|
333
|
+
# Ensure no lone decimal point
|
|
334
|
+
str = str.sub(/\.\z/, '')
|
|
335
|
+
|
|
336
|
+
# Final -0 check
|
|
337
|
+
str = '0' if ['-0', '-0.0'].include?(str)
|
|
338
|
+
|
|
339
|
+
str
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# FIXED: Correct string quoting - only leading/trailing spaces!
|
|
343
|
+
def format_string(str)
|
|
344
|
+
needs_string_quotes?(str) ? quote_string(str) : str
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def needs_string_quotes?(str)
|
|
348
|
+
return true if str.empty?
|
|
349
|
+
return true if str.include?(@delimiter)
|
|
350
|
+
return true if str.include?(':')
|
|
351
|
+
return true if str.include?('"') || str.include?('\\')
|
|
352
|
+
return true if str.match?(/[\n\r\t]/)
|
|
353
|
+
|
|
354
|
+
# Quote strings that contain spaces (inner or leading/trailing)
|
|
355
|
+
return true if str.include?(' ')
|
|
356
|
+
|
|
357
|
+
return true if str == '-' || str.start_with?('- ')
|
|
358
|
+
return true if looks_like_boolean?(str)
|
|
359
|
+
return true if looks_like_null?(str)
|
|
360
|
+
return true if looks_like_number?(str)
|
|
361
|
+
return true if looks_like_structural?(str)
|
|
362
|
+
|
|
363
|
+
false
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def looks_like_boolean?(str)
|
|
367
|
+
str.match?(/\A(true|false)\z/i)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def looks_like_null?(str)
|
|
371
|
+
str.match?(/\Anull\z/i)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def looks_like_number?(str)
|
|
375
|
+
str.match?(/\A-?\d+(\.\d+)?([eE][+-]?\d+)?\z/)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def looks_like_structural?(str)
|
|
379
|
+
str.match?(/\A\[.*\]\z/) || str.match?(/\A\{.*\}\z/)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def quote_string(str)
|
|
383
|
+
escaped = str.gsub('\\', '\\\\\\\\')
|
|
384
|
+
.gsub('"', '\"')
|
|
385
|
+
.gsub("\n", '\n')
|
|
386
|
+
.gsub("\r", '\r')
|
|
387
|
+
.gsub("\t", '\t')
|
|
388
|
+
"\"#{escaped}\""
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def indent(depth)
|
|
392
|
+
' ' * (depth * @indent_size)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def emit_line(content)
|
|
396
|
+
@output << content.rstrip
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
data/lib/json_to_toon.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'json_to_toon/version'
|
|
4
|
+
require_relative 'json_to_toon/encoder'
|
|
5
|
+
|
|
6
|
+
module JsonToToon
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class CircularReferenceError < Error; end
|
|
9
|
+
class InvalidOptionError < Error; end
|
|
10
|
+
|
|
11
|
+
# Convert a Ruby object to TOON format
|
|
12
|
+
#
|
|
13
|
+
# @param value [Object] The Ruby object to encode (Hash, Array, or primitive)
|
|
14
|
+
# @param options [Hash] Encoding options
|
|
15
|
+
# @option options [Integer] :indent Number of spaces per indentation level (default: 2)
|
|
16
|
+
# @option options [String] :delimiter Delimiter for arrays: ',' (default), "\t", or '|'
|
|
17
|
+
# @option options [String, false] :length_marker Length marker character or false (default: false)
|
|
18
|
+
# @return [String] TOON-formatted string with no trailing newline
|
|
19
|
+
# @raise [InvalidOptionError] If options are invalid
|
|
20
|
+
# @raise [CircularReferenceError] If circular references detected
|
|
21
|
+
#
|
|
22
|
+
# @example Basic usage
|
|
23
|
+
# JsonToToon.encode({name: 'Ada', age: 30})
|
|
24
|
+
# # => "name: Ada\nage: 30"
|
|
25
|
+
#
|
|
26
|
+
# @example With tab delimiter
|
|
27
|
+
# JsonToToon.encode({tags: ['a', 'b']}, delimiter: "\t")
|
|
28
|
+
# # => "tags[2\t]: a\tb"
|
|
29
|
+
def self.encode(value, options = {})
|
|
30
|
+
Encoder.new(options).encode(value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Alias for encode
|
|
34
|
+
def self.convert(value, options = {})
|
|
35
|
+
encode(value, options)
|
|
36
|
+
end
|
|
37
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby-json-toon
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jitendra Neema
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-01-06 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: benchmark-ips
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.12'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.12'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: memory_profiler
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rspec
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.12'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.12'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.50'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.50'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: rubocop-rake
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.7'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.7'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: rubocop-rspec
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '3.0'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '3.0'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: simplecov
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - "~>"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '0.22'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - "~>"
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '0.22'
|
|
125
|
+
description: Lightweight Ruby library for converting JSON data to TOON format, achieving
|
|
126
|
+
30-60% token reduction for LLM applications
|
|
127
|
+
email:
|
|
128
|
+
- jitenra.neema.8@gmail.com
|
|
129
|
+
executables: []
|
|
130
|
+
extensions: []
|
|
131
|
+
extra_rdoc_files: []
|
|
132
|
+
files:
|
|
133
|
+
- CHANGELOG.md
|
|
134
|
+
- LICENSE
|
|
135
|
+
- README.md
|
|
136
|
+
- lib/json_to_toon.rb
|
|
137
|
+
- lib/json_to_toon/encoder.rb
|
|
138
|
+
- lib/json_to_toon/version.rb
|
|
139
|
+
homepage: https://github.com/jitendra-neema/ruby-json-toon
|
|
140
|
+
licenses:
|
|
141
|
+
- MIT
|
|
142
|
+
metadata:
|
|
143
|
+
homepage_uri: https://github.com/jitendra-neema/ruby-json-toon
|
|
144
|
+
source_code_uri: https://github.com/jitendra-neema/ruby-json-toon
|
|
145
|
+
changelog_uri: https://github.com/jitendra-neema/ruby-json-toon/blob/main/CHANGELOG.md
|
|
146
|
+
bug_tracker_uri: https://github.com/jitendra-neema/ruby-json-toon/issues
|
|
147
|
+
documentation_uri: https://rubydoc.info/gems/ruby-json-toon
|
|
148
|
+
rubygems_mfa_required: 'true'
|
|
149
|
+
post_install_message:
|
|
150
|
+
rdoc_options: []
|
|
151
|
+
require_paths:
|
|
152
|
+
- lib
|
|
153
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
154
|
+
requirements:
|
|
155
|
+
- - ">="
|
|
156
|
+
- !ruby/object:Gem::Version
|
|
157
|
+
version: 2.7.0
|
|
158
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
159
|
+
requirements:
|
|
160
|
+
- - ">="
|
|
161
|
+
- !ruby/object:Gem::Version
|
|
162
|
+
version: '0'
|
|
163
|
+
requirements: []
|
|
164
|
+
rubygems_version: 3.4.19
|
|
165
|
+
signing_key:
|
|
166
|
+
specification_version: 4
|
|
167
|
+
summary: Convert JSON to TOON (Token-Oriented Object Notation)
|
|
168
|
+
test_files: []
|