toon-fu 4.1.0 → 4.1.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 +47 -9
- data/lib/toon-fu.rb +3 -0
- data/lib/toon_fu/encoder.rb +3 -1
- data/lib/toon_fu/fields.rb +4 -1
- data/lib/toon_fu/float_literal.rb +9 -0
- data/lib/toon_fu/normalizer.rb +45 -19
- data/lib/toon_fu/string_literal.rb +1 -1
- data/lib/toon_fu/version.rb +1 -1
- data/lib/toon_fu/writer.rb +1 -1
- data/lib/toon_fu.rb +11 -3
- metadata +11 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e095746e545c92bf6411c36b2042dde6d305deb9a1363d500d48877f09939b63
|
|
4
|
+
data.tar.gz: 937a2796b6a6f04afa2469994c94527473e93c66f575747adefea369691628d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 979b0210dfed7025dc7be6ba5c32362d7293ea394cb9fbf495374a17d136a9e81d9f8af0419278b891d39bde60c731624050efd4bf5c6ee3dfcc7858d7347b5e
|
|
7
|
+
data.tar.gz: 481896ac9ff0f47a297e794e90ebd3aae04d3dfbe1267a0e5b8c264f5ef4385a1816b0d241d0bd2ace3047d7748d9b4eefa2487cc251b3f264c8054967552d2c
|
data/README.md
CHANGED
|
@@ -14,17 +14,23 @@ TOON is a compact, readable encoding of the JSON data model for LLM prompts: ind
|
|
|
14
14
|
|
|
15
15
|
Every Ruby TOON gem on RubyGems was published in late 2025 and stopped at spec 1.2, three major versions behind. They leave strings starting with `#` or `+` unquoted, which a current reader takes for a comment or a number, and shift dates by a day east of Greenwich. toon-fu tracks the spec: its version is the spec version, and a daily check flags a newer spec.
|
|
16
16
|
|
|
17
|
+
Yes, we know:
|
|
18
|
+
|
|
19
|
+
[](https://xkcd.com/927/)
|
|
20
|
+
|
|
21
|
+
<sub>[xkcd #927 "Standards"](https://xkcd.com/927/) by Randall Munroe, [CC BY-NC 2.5](https://creativecommons.org/licenses/by-nc/2.5/).</sub>
|
|
22
|
+
|
|
17
23
|
## Getting started
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
Add it to your Gemfile:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "toon-fu"
|
|
21
29
|
```
|
|
22
30
|
|
|
23
|
-
In a
|
|
31
|
+
In a plain script: `gem install toon-fu` and `require "toon_fu"`. To pin the spec version, see [Versioning](#versioning).
|
|
24
32
|
|
|
25
33
|
```ruby
|
|
26
|
-
require "toon_fu"
|
|
27
|
-
|
|
28
34
|
ToonFu.encode({users: [{id: 1, name: "Ada", role: "admin"}, {id: 2, name: "Bob", role: "user"}]})
|
|
29
35
|
```
|
|
30
36
|
|
|
@@ -78,7 +84,7 @@ ToonFu.encode({price: Money.new(1999, "EUR")})
|
|
|
78
84
|
# currency: EUR
|
|
79
85
|
```
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
Any other value raises `ToonFu::Error`:
|
|
82
88
|
|
|
83
89
|
```ruby
|
|
84
90
|
ToonFu.encode({at: Object.new})
|
|
@@ -103,16 +109,48 @@ For ActiveRecord models, pass `record.as_json` (or define `as_toon`).
|
|
|
103
109
|
| objects with `to_hash`, `to_ary`, `to_str` | the value they convert to |
|
|
104
110
|
| objects with `as_toon` | whatever `as_toon` returns, encoded in turn |
|
|
105
111
|
|
|
106
|
-
Everything else raises `ToonFu::Error` — including a `Struct` or `Data` without `as_toon`, and
|
|
112
|
+
Everything else raises `ToonFu::Error` — including a `Struct` or `Data` without `as_toon`, circular references, and nesting too deep for the stack.
|
|
113
|
+
|
|
114
|
+
## Compared with other Ruby TOON gems
|
|
115
|
+
|
|
116
|
+
The only one that passes every spec fixture: toon-fu passes all 179 encode fixtures; the table compares the 154 that use default options, which every gem can run.
|
|
117
|
+
|
|
118
|
+
| Gem | Spec fixtures passed | Speed vs toon-fu |
|
|
119
|
+
|---|---:|---:|
|
|
120
|
+
| **toon-fu** | **154 / 154** | **1.00×** |
|
|
121
|
+
| sorbet-toon 0.1.0 | 119 / 154 | 0.59× |
|
|
122
|
+
| toon-ruby 0.1.1 | 117 / 154 | 0.62× |
|
|
123
|
+
| toon_my_json 0.1.0 | 57 / 154 | 1.68× |
|
|
124
|
+
| toon-format 0.1.2 | 45 / 154 | 1.18× |
|
|
125
|
+
|
|
126
|
+
The Ruby TOON encoders with more than 10,000 downloads, measured by [`benchmark/run.rb`](benchmark/run.rb) on Ruby 3.4.10 (2026-09-24). **Spec fixtures** are the spec's own encode fixtures that use default options. **Speed** is the geometric mean of encodes per second over five workloads — tables of 100 and 1000 rows, nested objects, a list of mixed objects, strings needing quotes — relative to toon-fu.
|
|
127
|
+
|
|
128
|
+
What falls through the gaps:
|
|
129
|
+
|
|
130
|
+
- **sorbet-toon, toon-ruby** — `#tag` and `+1` go out unquoted, so a current reader sees a comment and a number; arrays of objects with nested columns lose their table form; no keyed tables. Unknown objects slip through instead of raising: toon-ruby writes `null`, sorbet-toon `"#<Foo:0x…>"`. toon-ruby also moves a `Date` a day back east of Greenwich.
|
|
131
|
+
- **toon_my_json, toon-format** — output a TOON reader cannot read: the table header on its own line, a `[2,]` length, rows at the wrong depth, nested objects in cells as broken text; toon_my_json also writes `false` as `null`. They do less work, and it shows in both columns.
|
|
132
|
+
|
|
133
|
+
Rerun it:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
BUNDLE_GEMFILE=benchmark/Gemfile bundle install
|
|
137
|
+
BUNDLE_GEMFILE=benchmark/Gemfile bundle exec ruby benchmark/run.rb
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
To see where toon-fu itself spends time and allocates, profile one workload (CPU by stackprof, allocation sites by memory_profiler):
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
BUNDLE_GEMFILE=benchmark/Gemfile bundle exec ruby benchmark/profile.rb "table, 1000 rows"
|
|
144
|
+
```
|
|
107
145
|
|
|
108
146
|
## Versioning
|
|
109
147
|
|
|
110
148
|
The gem version tracks the TOON specification it implements:
|
|
111
149
|
|
|
112
150
|
- `MAJOR.MINOR` is the spec version: `X.Y.Z` speaks TOON `X.Y`.
|
|
113
|
-
- `PATCH` is the gem's own: fixes and improvements
|
|
151
|
+
- `PATCH` is the gem's own: fixes and improvements within the same dialect.
|
|
114
152
|
|
|
115
|
-
Pin the spec line
|
|
153
|
+
Pin the spec line with `gem "toon-fu", "~> X.Y.0"`: it takes our fixes and keeps you on the dialect you speak. The gem badge above shows the current release; the spec-drift badge turns red when a newer spec is released and toon-fu has not caught up yet.
|
|
116
154
|
|
|
117
155
|
Release notes: [GitHub releases](https://github.com/hoblin/toon-fu/releases).
|
|
118
156
|
|
data/lib/toon-fu.rb
ADDED
data/lib/toon_fu/encoder.rb
CHANGED
|
@@ -44,9 +44,11 @@ module ToonFu
|
|
|
44
44
|
# Symbol or Integer, and keys that collide once converted to strings;
|
|
45
45
|
# for strings that are not valid UTF-8; for circular references,
|
|
46
46
|
# including an +as_toon+ or implicit conversion that leads back to its
|
|
47
|
-
# own object
|
|
47
|
+
# own object, and for nesting too deep for the stack
|
|
48
48
|
def encode(value)
|
|
49
49
|
Writer.new(@delimiter, @indent).write(Normalizer.new.call(value))
|
|
50
|
+
rescue SystemStackError
|
|
51
|
+
raise Error, "cannot encode a circular reference or nesting too deep"
|
|
50
52
|
end
|
|
51
53
|
end
|
|
52
54
|
end
|
data/lib/toon_fu/fields.rb
CHANGED
|
@@ -23,12 +23,15 @@ module ToonFu
|
|
|
23
23
|
new(columns)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
attr_reader :paths
|
|
27
|
+
|
|
26
28
|
def initialize(columns)
|
|
27
29
|
@columns = columns
|
|
30
|
+
@paths = columns.flat_map { |key, nested| nested ? nested.paths.map { |path| [key, *path] } : [[key]] }
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
def cells(row)
|
|
31
|
-
@
|
|
34
|
+
@paths.map { |path| path.reduce(row) { |node, key| node[key] } }
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
end
|
|
@@ -4,6 +4,15 @@ module ToonFu
|
|
|
4
4
|
class FloatLiteral
|
|
5
5
|
DECIMAL_RANGE = (1e-6...1e21)
|
|
6
6
|
|
|
7
|
+
def self.format(value)
|
|
8
|
+
plain = value.to_s
|
|
9
|
+
return new(value).to_s if plain.include?("e") || !value.finite?
|
|
10
|
+
|
|
11
|
+
return "0" if value.zero?
|
|
12
|
+
|
|
13
|
+
plain.end_with?(".0") ? plain.delete_suffix(".0") : plain
|
|
14
|
+
end
|
|
15
|
+
|
|
7
16
|
def initialize(value)
|
|
8
17
|
@value = value
|
|
9
18
|
end
|
data/lib/toon_fu/normalizer.rb
CHANGED
|
@@ -4,15 +4,11 @@ module ToonFu
|
|
|
4
4
|
class Normalizer
|
|
5
5
|
TRAILING_FRACTION_ZEROS = /\.?0+\z/
|
|
6
6
|
|
|
7
|
-
def initialize
|
|
8
|
-
@path = {}.compare_by_identity
|
|
9
|
-
end
|
|
10
|
-
|
|
11
7
|
def call(value)
|
|
12
8
|
raise Error, "cannot encode a BasicObject" unless Kernel === value
|
|
13
9
|
return core(value) unless value.respond_to?(:as_toon)
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
call(value.as_toon)
|
|
16
12
|
end
|
|
17
13
|
|
|
18
14
|
private
|
|
@@ -22,8 +18,9 @@ module ToonFu
|
|
|
22
18
|
when nil, true, false, Integer, Float then value
|
|
23
19
|
when String then utf8(value)
|
|
24
20
|
when Symbol then utf8(value.name)
|
|
25
|
-
when Hash then
|
|
26
|
-
when Array
|
|
21
|
+
when Hash then object(value)
|
|
22
|
+
when Array then array(value)
|
|
23
|
+
when Set then value.map { |element| call(element) }
|
|
27
24
|
when Time then timestamp(value)
|
|
28
25
|
when DateTime then date_time(value)
|
|
29
26
|
when Date then value.iso8601
|
|
@@ -33,14 +30,50 @@ module ToonFu
|
|
|
33
30
|
|
|
34
31
|
def convert(value)
|
|
35
32
|
if defined?(BigDecimal) && value.is_a?(BigDecimal) then DecimalLiteral.new(value)
|
|
36
|
-
elsif value.respond_to?(:to_hash) then
|
|
37
|
-
elsif value.respond_to?(:to_ary) then
|
|
38
|
-
elsif value.respond_to?(:to_str) then
|
|
33
|
+
elsif value.respond_to?(:to_hash) then call(value.to_hash)
|
|
34
|
+
elsif value.respond_to?(:to_ary) then call(value.to_ary)
|
|
35
|
+
elsif value.respond_to?(:to_str) then call(value.to_str)
|
|
39
36
|
else raise Error, "cannot encode #{value.class}; convert it first or define #as_toon"
|
|
40
37
|
end
|
|
41
38
|
end
|
|
42
39
|
|
|
40
|
+
def array(values)
|
|
41
|
+
return values.map { |element| call(element) } unless values.instance_of?(Array)
|
|
42
|
+
|
|
43
|
+
copy = nil
|
|
44
|
+
index = 0
|
|
45
|
+
while index < values.size
|
|
46
|
+
element = values[index]
|
|
47
|
+
normal = call(element)
|
|
48
|
+
unless copy.nil? && normal.equal?(element)
|
|
49
|
+
copy ||= values.first(index)
|
|
50
|
+
copy << normal
|
|
51
|
+
end
|
|
52
|
+
index += 1
|
|
53
|
+
end
|
|
54
|
+
copy || values
|
|
55
|
+
end
|
|
56
|
+
|
|
43
57
|
def object(hash)
|
|
58
|
+
return rebuild(hash) unless plain?(hash)
|
|
59
|
+
|
|
60
|
+
copy = nil
|
|
61
|
+
hash.each do |key, value|
|
|
62
|
+
normal = call(value)
|
|
63
|
+
next if copy.nil? && normal.equal?(value)
|
|
64
|
+
|
|
65
|
+
copy ||= hash.take_while { |pair_key, _| !pair_key.equal?(key) }.to_h
|
|
66
|
+
copy[key] = normal
|
|
67
|
+
end
|
|
68
|
+
copy || hash
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def plain?(hash)
|
|
72
|
+
hash.instance_of?(Hash) && !hash.compare_by_identity? &&
|
|
73
|
+
hash.all? { |key, _| key.instance_of?(String) && key.encoding == Encoding::UTF_8 && key.valid_encoding? }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def rebuild(hash)
|
|
44
77
|
hash.each_with_object({}) do |(key, value), result|
|
|
45
78
|
name = key_name(key)
|
|
46
79
|
raise Error, "duplicate key #{name.inspect} after converting keys to strings" if result.key?(name)
|
|
@@ -58,15 +91,6 @@ module ToonFu
|
|
|
58
91
|
end
|
|
59
92
|
end
|
|
60
93
|
|
|
61
|
-
def within(container)
|
|
62
|
-
raise Error, "cannot encode a circular reference through #{container.class}" if @path.key?(container)
|
|
63
|
-
|
|
64
|
-
@path[container] = true
|
|
65
|
-
result = yield
|
|
66
|
-
@path.delete(container)
|
|
67
|
-
result
|
|
68
|
-
end
|
|
69
|
-
|
|
70
94
|
def timestamp(time)
|
|
71
95
|
moment = time.strftime("%Y-%m-%dT%H:%M:%S.%9N").sub(TRAILING_FRACTION_ZEROS, "")
|
|
72
96
|
"#{moment}#{time.utc? ? "Z" : time.strftime("%:z")}"
|
|
@@ -78,6 +102,8 @@ module ToonFu
|
|
|
78
102
|
end
|
|
79
103
|
|
|
80
104
|
def utf8(string)
|
|
105
|
+
return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
|
|
106
|
+
|
|
81
107
|
string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding == Encoding::BINARY
|
|
82
108
|
string = string.encode(Encoding::UTF_8) unless string.encoding == Encoding::UTF_8
|
|
83
109
|
raise Error, "cannot encode a string that is not valid UTF-8: #{string.inspect}" unless string.valid_encoding?
|
|
@@ -4,7 +4,7 @@ module ToonFu
|
|
|
4
4
|
class StringLiteral
|
|
5
5
|
READS_AS_LITERAL = /\A(?:true|false|null|[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\z/
|
|
6
6
|
UNSAFE = Encoder::DELIMITERS.to_h do |delimiter|
|
|
7
|
-
[delimiter, /\A[ \t#-]|[ \t]\z|[:"\\\[\]{}\x00-\x1f#{Regexp.escape(delimiter)}]/]
|
|
7
|
+
[delimiter, /\A[ \t#-]|[ \t]\z|[:"\\\[\]{}\x00-\x1f#{Regexp.escape(delimiter) if delimiter.ord > 0x1f}]/]
|
|
8
8
|
end.freeze
|
|
9
9
|
BARE_KEY = /\A[A-Za-z_][A-Za-z0-9_.]*\z/
|
|
10
10
|
ESCAPABLE = /["\\\x00-\x1f]/
|
data/lib/toon_fu/version.rb
CHANGED
data/lib/toon_fu/writer.rb
CHANGED
|
@@ -118,7 +118,7 @@ module ToonFu
|
|
|
118
118
|
case value
|
|
119
119
|
when nil then "null"
|
|
120
120
|
when true, false, Integer then value.to_s
|
|
121
|
-
when Float then FloatLiteral.
|
|
121
|
+
when Float then FloatLiteral.format(value)
|
|
122
122
|
when DecimalLiteral then value.to_s
|
|
123
123
|
when String then @strings.encode(value)
|
|
124
124
|
else raise Error, "cannot encode #{value.class}"
|
data/lib/toon_fu.rb
CHANGED
|
@@ -30,11 +30,19 @@ module ToonFu
|
|
|
30
30
|
# ToonFu.encode([{id: 1}, {id: 2}]) # => "[2]{id}:\n 1\n 2"
|
|
31
31
|
# ToonFu.encode({a: {x: 1}, b: {x: 2}}) # => "[2:]{x}:\n a: 1\n b: 2"
|
|
32
32
|
#
|
|
33
|
-
# @param value [Object] see {Encoder#encode} for the accepted types
|
|
33
|
+
# @param value [Object] see {Encoder#encode} for the accepted types; a Hash
|
|
34
|
+
# needs braces, since bare +key: value+ pairs are Ruby keyword arguments
|
|
34
35
|
# @param options [Hash] see {Encoder#initialize}
|
|
35
36
|
# @return [String]
|
|
36
37
|
# @raise [Error] see {Encoder#encode}
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
# @raise [ArgumentError] when no value is given, as with a Hash written
|
|
39
|
+
# without braces
|
|
40
|
+
def self.encode(value = (missing = true), **options)
|
|
41
|
+
raise ArgumentError, "ToonFu.encode takes the value as its first argument; wrap a Hash in braces: ToonFu.encode({key: value})" if missing
|
|
42
|
+
|
|
43
|
+
(options.empty? ? DEFAULT_ENCODER : Encoder.new(**options)).encode(value)
|
|
39
44
|
end
|
|
45
|
+
|
|
46
|
+
DEFAULT_ENCODER = Ractor.make_shareable(Encoder.new)
|
|
47
|
+
private_constant :DEFAULT_ENCODER
|
|
40
48
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: toon-fu
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.1.
|
|
4
|
+
version: 4.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yevhenii Hurin
|
|
@@ -9,9 +9,11 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description:
|
|
13
|
-
for LLM
|
|
14
|
-
|
|
12
|
+
description: 'Encodes Ruby hashes, arrays, dates and your own objects into TOON, the
|
|
13
|
+
token-efficient format for LLM prompts: tables for uniform arrays, quotes only where
|
|
14
|
+
needed. The only Ruby TOON gem that passes all of the current spec''s encode fixtures;
|
|
15
|
+
its version tracks the spec it implements, and anything it cannot represent raises
|
|
16
|
+
an error so your data arrives complete.'
|
|
15
17
|
email:
|
|
16
18
|
- evgeny.gurin@gmail.com
|
|
17
19
|
executables: []
|
|
@@ -20,6 +22,7 @@ extra_rdoc_files: []
|
|
|
20
22
|
files:
|
|
21
23
|
- LICENSE
|
|
22
24
|
- README.md
|
|
25
|
+
- lib/toon-fu.rb
|
|
23
26
|
- lib/toon_fu.rb
|
|
24
27
|
- lib/toon_fu/decimal_literal.rb
|
|
25
28
|
- lib/toon_fu/encodable.rb
|
|
@@ -36,6 +39,8 @@ licenses:
|
|
|
36
39
|
metadata:
|
|
37
40
|
source_code_uri: https://github.com/hoblin/toon-fu
|
|
38
41
|
changelog_uri: https://github.com/hoblin/toon-fu/releases
|
|
42
|
+
documentation_uri: https://rubydoc.info/gems/toon-fu
|
|
43
|
+
bug_tracker_uri: https://github.com/hoblin/toon-fu/issues
|
|
39
44
|
rubygems_mfa_required: 'true'
|
|
40
45
|
rdoc_options: []
|
|
41
46
|
require_paths:
|
|
@@ -53,6 +58,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
53
58
|
requirements: []
|
|
54
59
|
rubygems_version: 3.6.9
|
|
55
60
|
specification_version: 4
|
|
56
|
-
summary: TOON
|
|
57
|
-
|
|
61
|
+
summary: TOON encoder for Ruby that passes every spec fixture — compact, token-efficient
|
|
62
|
+
LLM input from your Ruby data
|
|
58
63
|
test_files: []
|