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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6eda50e6a13e61ee805fbaf15f417176c2463648bc226af942aaa383ee50cc5c
4
- data.tar.gz: 65406f2ccd9d8e643c9f26109d16d52d3361c92e200323e235e52ec572669461
3
+ metadata.gz: e095746e545c92bf6411c36b2042dde6d305deb9a1363d500d48877f09939b63
4
+ data.tar.gz: 937a2796b6a6f04afa2469994c94527473e93c66f575747adefea369691628d4
5
5
  SHA512:
6
- metadata.gz: ef2e69c9a75e69600957f6ab92e139ee522d960cafc2793aa7621854fa9c5bf667b7c197f2b32820b361af3070b68769dfc86c8e639a428c8bec8ad61ee6ee99
7
- data.tar.gz: 8d5ced2be98d6bba1e423a75aac5eb22992bbfa8e76b1cf5cc2a33f09a0b1eea96590c4a8b33970768cd9885d30a39e0fc8db8522c263c60d93130d1c7043112
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
+ [![xkcd 927: Standards](https://imgs.xkcd.com/comics/standards.png)](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
- ```bash
20
- gem install toon-fu
25
+ Add it to your Gemfile:
26
+
27
+ ```ruby
28
+ gem "toon-fu"
21
29
  ```
22
30
 
23
- In a Gemfile, pin the spec line you speak — see [Versioning](#versioning).
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
- Anything toon-fu does not know raises `ToonFu::Error` instead of guessing:
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 circular references.
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 that do not change the dialect.
151
+ - `PATCH` is the gem's own: fixes and improvements within the same dialect.
114
152
 
115
- Pin the spec line, not just the major: `gem "toon-fu", "~> X.Y.0"` takes our fixes and never moves you to a new dialect. 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.
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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "toon_fu"
@@ -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
@@ -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
- @columns.flat_map { |key, nested| nested ? nested.cells(row[key]) : [row[key]] }
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
@@ -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
- within(value) { call(value.as_toon) }
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 within(value) { object(value) }
26
- when Array, Set then within(value) { value.map { |element| call(element) } }
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 within(value) { call(value.to_hash) }
37
- elsif value.respond_to?(:to_ary) then within(value) { call(value.to_ary) }
38
- elsif value.respond_to?(:to_str) then within(value) { call(value.to_str) }
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]/
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ToonFu
4
- VERSION = "4.1.0"
4
+ VERSION = "4.1.2"
5
5
  end
@@ -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.new(value).to_s
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
- def self.encode(value, **options)
38
- Encoder.new(**options).encode(value)
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.0
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: A Ruby implementation of TOON, the token-efficient serialization format
13
- for LLM input. The gem's MAJOR.MINOR tracks the TOON specification version; PATCH
14
- is the gem's own.
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 (Token-Oriented Object Notation) for Ruby, versioned by the spec it
57
- implements
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: []