toon-fu 0.0.1 → 4.1.1

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: 209aece0889367dd31ee2cf3bd80fb03094022b6be61967c6b9f400c5b02d014
4
- data.tar.gz: 33ce4ad50d1bc81088e25fa426c6b2cbd73e086593bd8b822240d6161f20b61b
3
+ metadata.gz: ea42b008c3b7cb955d4121725dc71cb9d0682383adb6f082fd59db8ed5d71da8
4
+ data.tar.gz: 8fd5631c7400ccd2eddfa648b2b4136a74e26fcf9b89927e5d143a0782205d0a
5
5
  SHA512:
6
- metadata.gz: 12340212191070a117e650bec7d7861fdd05b57c4725816264474e30a4fa3c9244c70bc8ff2cc6c754f50ba5f948fef05f6b5841cb889bd367faa43432288bb6
7
- data.tar.gz: 0c6e732a2adfdf17c7ca269dcfbcb3a83adcc4ea68b878244c565f5c1f0d4040746fe2098a36645e7baf0f47cbbcf1366bce6cd28add46de1abac15ba8ac1693
6
+ metadata.gz: 4a5c4ce7f285972990ca07320cea5484de95f560be64b714f847c9b703f015700f9369f2fdfaa5599216b59e5216f991b722cc7c3cd3faf694f8160d1ff9261a
7
+ data.tar.gz: afceb6dbe076e69b78b8c58a2d52d0baed6279eef642efc7d6023d0290c7b795b6c84b64f3c7200acbdb9057af21804a341a4e616275bb66cee13db0035ab14b
data/README.md CHANGED
@@ -1,19 +1,178 @@
1
1
  # toon-fu
2
2
 
3
- [TOON](https://github.com/toon-format/spec) (Token-Oriented Object Notation) for Ruby.
3
+ [![CI](https://github.com/hoblin/toon-fu/actions/workflows/ci.yml/badge.svg)](https://github.com/hoblin/toon-fu/actions/workflows/ci.yml)
4
+ [![Spec drift](https://github.com/hoblin/toon-fu/actions/workflows/spec-drift.yml/badge.svg)](https://github.com/hoblin/toon-fu/actions/workflows/spec-drift.yml)
5
+ [![Gem](https://img.shields.io/gem/v/toon-fu)](https://rubygems.org/gems/toon-fu)
4
6
 
5
- Written from the specification, with the reference test fixtures as the conformance suite.
7
+ [TOON](https://toonformat.dev/) (Token-Oriented Object Notation) encoder for Ruby, versioned by the spec it implements: the gem's `MAJOR.MINOR` is the TOON spec version it speaks.
8
+
9
+ ## What is this?
10
+
11
+ TOON is a compact, readable encoding of the JSON data model for LLM prompts: indentation instead of braces, quotes only where needed, and tables for arrays of uniform objects. toon-fu is written from the [specification](https://github.com/toon-format/spec) and runs the spec's reference fixtures as its conformance suite — every encode fixture of the spec version it implements passes.
12
+
13
+ ## Why?
14
+
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
+
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
+
23
+ ## Getting started
24
+
25
+ Add it to your Gemfile:
26
+
27
+ ```ruby
28
+ gem "toon-fu"
29
+ ```
30
+
31
+ In a plain script: `gem install toon-fu` and `require "toon_fu"`. To pin the spec version, see [Versioning](#versioning).
32
+
33
+ ```ruby
34
+ ToonFu.encode({users: [{id: 1, name: "Ada", role: "admin"}, {id: 2, name: "Bob", role: "user"}]})
35
+ ```
36
+
37
+ ```
38
+ users[2]{id,name,role}:
39
+ 1,Ada,admin
40
+ 2,Bob,user
41
+ ```
42
+
43
+ Arrays of uniform objects become tables that declare their fields once. Everything else reads like YAML:
44
+
45
+ ```ruby
46
+ {order: {id: 7, tags: ["new", "paid"], customer: {name: "Ada"}}}.to_toon
47
+ ```
48
+
49
+ ```
50
+ order:
51
+ id: 7
52
+ tags[2]: new,paid
53
+ customer:
54
+ name: Ada
55
+ ```
56
+
57
+ `to_toon` is available on Hash, Array, String, Symbol, Integer, Float, `true`, `false`, `nil`, Set, Time and Date once `toon_fu` is loaded.
58
+
59
+ ### Options
60
+
61
+ - `delimiter:` — `","` (default), `"\t"` or `"|"`. Tab usually costs the fewest tokens.
62
+ - `indent_size:` — spaces per nesting level, default `2`.
63
+
64
+ ```ruby
65
+ ToonFu.encode(data, delimiter: "\t")
66
+ encoder = ToonFu::Encoder.new(delimiter: "|") # reuse for many documents
67
+ encoder.encode(data)
68
+ ```
69
+
70
+ ### Your own objects
71
+
72
+ Define `as_toon` to return plain data; it wins over every built-in mapping:
73
+
74
+ ```ruby
75
+ Money = Struct.new(:cents, :currency) do
76
+ include ToonFu::Encodable # optional: adds #to_toon
77
+
78
+ def as_toon = {amount: cents / 100.0, currency:}
79
+ end
80
+
81
+ ToonFu.encode({price: Money.new(1999, "EUR")})
82
+ # price:
83
+ # amount: 19.99
84
+ # currency: EUR
85
+ ```
86
+
87
+ Any other value raises `ToonFu::Error`:
88
+
89
+ ```ruby
90
+ ToonFu.encode({at: Object.new})
91
+ # ToonFu::Error: cannot encode Object; convert it first or define #as_toon
92
+ ```
93
+
94
+ For ActiveRecord models, pass `record.as_json` (or define `as_toon`).
95
+
96
+ ## What it accepts
97
+
98
+ | Ruby value | TOON |
99
+ |---|---|
100
+ | `Hash` with String, Symbol or Integer keys | object; keys become strings, keys that collide raise |
101
+ | `Array`, `Set` | array: inline, table, or list form |
102
+ | `String` | string, quoted only when needed; other encodings are transcoded to UTF-8, invalid UTF-8 raises |
103
+ | `Symbol` | its name |
104
+ | `Integer` | its exact digits, any size |
105
+ | `Float`, `BigDecimal` | canonical number; NaN and infinities become `null` |
106
+ | `true`, `false`, `nil` | `true`, `false`, `null` |
107
+ | `Date` | `2026-05-31` |
108
+ | `Time`, `DateTime` | ISO 8601 with its offset: `2026-05-31T10:00:05.25Z` |
109
+ | objects with `to_hash`, `to_ary`, `to_str` | the value they convert to |
110
+ | objects with `as_toon` | whatever `as_toon` returns, encoded in turn |
111
+
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
+ ```
6
145
 
7
146
  ## Versioning
8
147
 
9
148
  The gem version tracks the TOON specification it implements:
10
149
 
11
- - `MAJOR.MINOR` is the spec version. `4.1.x` speaks TOON 4.1.
12
- - `PATCH` is the gem's own: fixes and improvements that do not change the dialect.
150
+ - `MAJOR.MINOR` is the spec version: `X.Y.Z` speaks TOON `X.Y`.
151
+ - `PATCH` is the gem's own: fixes and improvements within the same dialect.
152
+
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.
154
+
155
+ Release notes: [GitHub releases](https://github.com/hoblin/toon-fu/releases).
156
+
157
+ ## Development
158
+
159
+ ```bash
160
+ git clone --recurse-submodules git@github.com:hoblin/toon-fu.git
161
+ cd toon-fu
162
+ bundle install
163
+ bundle exec rspec # unit specs + the spec's conformance fixtures
164
+ bundle exec standardrb # lint
165
+ ```
166
+
167
+ The TOON spec is a git submodule at `spec/toon-spec`, pinned to its release tag; the fixtures run from there.
168
+
169
+ ## Releasing
13
170
 
14
- Pin to the spec line you need: `gem "toon-fu", "~> 4.1.0"`.
171
+ 1. Bump `lib/toon_fu/version.rb` in a pull request and merge it.
172
+ 2. `git tag vX.Y.Z && git push origin vX.Y.Z` on `main`.
173
+ 3. Approve the `release` deployment in Actions.
15
174
 
16
- `0.0.1` is a placeholder that claims the name. The first real release will be `4.1.0`.
175
+ The [release workflow](.github/workflows/release.yml) checks the tag matches the version, runs CI, and publishes to RubyGems via [trusted publishing](https://guides.rubygems.org/trusted-publishing/).
17
176
 
18
177
  ## License
19
178
 
data/lib/toon-fu.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "toon_fu"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class DecimalLiteral
5
+ def initialize(value)
6
+ @value = value
7
+ end
8
+
9
+ def to_s
10
+ return "null" unless @value.finite?
11
+ return "0" if @value.zero?
12
+ return @value.to_s("F").delete_suffix(".0") if FloatLiteral::DECIMAL_RANGE.cover?(@value.abs)
13
+
14
+ sign, digits, _base, exponent = @value.split
15
+ mantissa = (digits.size > 1) ? "#{digits[0]}.#{digits[1..]}" : digits
16
+ "#{"-" if sign.negative?}#{mantissa}e#{format("%+d", exponent - 1)}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ # Adds +to_toon+ to the core and standard-library classes TOON encodes.
5
+ # +BigDecimal+ is left out: it is a bundled gem the encoder accepts but
6
+ # does not load. Include this module into a class that defines +as_toon+
7
+ # to give that class +to_toon+ as well.
8
+ module Encodable
9
+ # @param options [Hash] see {Encoder#initialize}
10
+ # @return [String] the receiver encoded as TOON, as {ToonFu.encode} would
11
+ # @raise [Error] see {Encoder#encode}
12
+ def to_toon(**options)
13
+ ToonFu.encode(self, **options)
14
+ end
15
+ end
16
+
17
+ [Hash, Array, Set, String, Symbol, Integer, Float, TrueClass, FalseClass, NilClass, Time, Date].each do |klass|
18
+ klass.include(Encodable)
19
+ end
20
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ # Encodes values with one set of options, for callers that encode many.
5
+ class Encoder
6
+ DELIMITERS = [",", "\t", "|"].freeze
7
+
8
+ # @param delimiter [String] the document delimiter, one of {DELIMITERS};
9
+ # strings containing it are quoted
10
+ # @param indent_size [Integer] spaces per nesting level
11
+ # @raise [ArgumentError] when the delimiter is not one of {DELIMITERS}
12
+ # or indent_size is not a positive Integer
13
+ def initialize(delimiter: ",", indent_size: 2)
14
+ raise ArgumentError, "delimiter must be one of #{DELIMITERS.inspect}, got #{delimiter.inspect}" unless DELIMITERS.include?(delimiter)
15
+ raise ArgumentError, "indent_size must be a positive Integer, got #{indent_size.inspect}" unless indent_size.is_a?(Integer) && indent_size.positive?
16
+
17
+ @delimiter = delimiter
18
+ @indent = " " * indent_size
19
+ end
20
+
21
+ # Encodes a value as TOON.
22
+ #
23
+ # Accepts the JSON data model plus these Ruby types, nested in any
24
+ # combination:
25
+ #
26
+ # - +Hash+ with String, Symbol or Integer keys, which become strings;
27
+ # +Array+; +Set+ as an array
28
+ # - +String+ in UTF-8, in an encoding that transcodes to it, or binary
29
+ # bytes that are valid UTF-8; +Symbol+ as its name
30
+ # - +Integer+ of any size as its exact digits; +Float+ and +BigDecimal+,
31
+ # with NaN and infinities as +null+, +BigDecimal+ as its exact digits;
32
+ # +true+, +false+, +nil+
33
+ # - +Date+ as an ISO 8601 date; +Time+ and +DateTime+ as ISO 8601
34
+ # timestamps keeping their offset, fraction digits up to the last
35
+ # non-zero one
36
+ # - objects that declare themselves a Hash, Array or String through
37
+ # Ruby's implicit conversions: +to_hash+, +to_ary+, +to_str+
38
+ # - any object responding to +as_toon+: its result is encoded instead,
39
+ # ahead of the mappings above
40
+ #
41
+ # @param value [Object] one of the types above
42
+ # @return [String] UTF-8
43
+ # @raise [Error] for any other value type; for keys other than String,
44
+ # Symbol or Integer, and keys that collide once converted to strings;
45
+ # for strings that are not valid UTF-8; for circular references,
46
+ # including an +as_toon+ or implicit conversion that leads back to its
47
+ # own object, and for nesting too deep for the stack
48
+ def encode(value)
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"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class Fields
5
+ attr_reader :columns
6
+
7
+ def self.of(rows)
8
+ return if rows.empty? || !rows.all?(Hash)
9
+
10
+ keys = rows.first.keys
11
+ return if keys.empty?
12
+ return unless rows.all? { |row| row.size == keys.size && keys.all? { |key| row.key?(key) } }
13
+
14
+ columns = keys.to_h do |key|
15
+ values = rows.map { |row| row[key] }
16
+ next [key, nil] if values.none? { |value| value.is_a?(Hash) || value.is_a?(Array) }
17
+
18
+ nested = of(values)
19
+ return nil unless nested
20
+
21
+ [key, nested]
22
+ end
23
+ new(columns)
24
+ end
25
+
26
+ attr_reader :paths
27
+
28
+ def initialize(columns)
29
+ @columns = columns
30
+ @paths = columns.flat_map { |key, nested| nested ? nested.paths.map { |path| [key, *path] } : [[key]] }
31
+ end
32
+
33
+ def cells(row)
34
+ @paths.map { |path| path.reduce(row) { |node, key| node[key] } }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class FloatLiteral
5
+ DECIMAL_RANGE = (1e-6...1e21)
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
+
16
+ def initialize(value)
17
+ @value = value
18
+ end
19
+
20
+ def to_s
21
+ return "null" unless @value.finite?
22
+ return "0" if @value.zero?
23
+
24
+ DECIMAL_RANGE.cover?(@value.abs) ? decimal : exponential
25
+ end
26
+
27
+ private
28
+
29
+ def decimal
30
+ plain = @value.to_s
31
+ return plain.delete_suffix(".0") unless plain.include?("e")
32
+
33
+ digits, point = significand
34
+ text =
35
+ if point <= 0
36
+ "0.#{"0" * -point}#{digits}"
37
+ elsif point >= digits.length
38
+ digits.ljust(point, "0")
39
+ else
40
+ "#{digits[0, point]}.#{digits[point..]}"
41
+ end
42
+ sign + text
43
+ end
44
+
45
+ def exponential
46
+ mantissa, exponent = @value.abs.to_s.split("e")
47
+ "#{sign}#{mantissa.delete_suffix(".0")}e#{format("%+d", exponent.to_i)}"
48
+ end
49
+
50
+ def significand
51
+ mantissa, exponent = @value.abs.to_s.split("e")
52
+ whole, fraction = mantissa.split(".")
53
+ fraction = "" if fraction == "0"
54
+ [whole + fraction, whole.length + exponent.to_i]
55
+ end
56
+
57
+ def sign
58
+ @value.negative? ? "-" : ""
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class Normalizer
5
+ TRAILING_FRACTION_ZEROS = /\.?0+\z/
6
+
7
+ def call(value)
8
+ raise Error, "cannot encode a BasicObject" unless Kernel === value
9
+ return core(value) unless value.respond_to?(:as_toon)
10
+
11
+ call(value.as_toon)
12
+ end
13
+
14
+ private
15
+
16
+ def core(value)
17
+ case value
18
+ when nil, true, false, Integer, Float then value
19
+ when String then utf8(value)
20
+ when Symbol then utf8(value.name)
21
+ when Hash then object(value)
22
+ when Array then array(value)
23
+ when Set then value.map { |element| call(element) }
24
+ when Time then timestamp(value)
25
+ when DateTime then date_time(value)
26
+ when Date then value.iso8601
27
+ else convert(value)
28
+ end
29
+ end
30
+
31
+ def convert(value)
32
+ if defined?(BigDecimal) && value.is_a?(BigDecimal) then DecimalLiteral.new(value)
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)
36
+ else raise Error, "cannot encode #{value.class}; convert it first or define #as_toon"
37
+ end
38
+ end
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
+
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)
77
+ hash.each_with_object({}) do |(key, value), result|
78
+ name = key_name(key)
79
+ raise Error, "duplicate key #{name.inspect} after converting keys to strings" if result.key?(name)
80
+
81
+ result[name] = call(value)
82
+ end
83
+ end
84
+
85
+ def key_name(key)
86
+ case key
87
+ when String then utf8(key)
88
+ when Symbol then utf8(key.name)
89
+ when Integer then key.to_s
90
+ else raise Error, "cannot encode #{key.class} keys; use String, Symbol or Integer keys"
91
+ end
92
+ end
93
+
94
+ def timestamp(time)
95
+ moment = time.strftime("%Y-%m-%dT%H:%M:%S.%9N").sub(TRAILING_FRACTION_ZEROS, "")
96
+ "#{moment}#{time.utc? ? "Z" : time.strftime("%:z")}"
97
+ end
98
+
99
+ def date_time(value)
100
+ moment, offset = value.iso8601(9).split(/(?=[+-]\d\d:\d\d\z)/)
101
+ "#{moment.sub(TRAILING_FRACTION_ZEROS, "")}#{offset}"
102
+ end
103
+
104
+ def utf8(string)
105
+ return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
106
+
107
+ string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding == Encoding::BINARY
108
+ string = string.encode(Encoding::UTF_8) unless string.encoding == Encoding::UTF_8
109
+ raise Error, "cannot encode a string that is not valid UTF-8: #{string.inspect}" unless string.valid_encoding?
110
+
111
+ string
112
+ rescue EncodingError => error
113
+ raise Error, "cannot encode a string as UTF-8: #{error.message}"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class StringLiteral
5
+ READS_AS_LITERAL = /\A(?:true|false|null|[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\z/
6
+ UNSAFE = Encoder::DELIMITERS.to_h do |delimiter|
7
+ [delimiter, /\A[ \t#-]|[ \t]\z|[:"\\\[\]{}\x00-\x1f#{Regexp.escape(delimiter) if delimiter.ord > 0x1f}]/]
8
+ end.freeze
9
+ BARE_KEY = /\A[A-Za-z_][A-Za-z0-9_.]*\z/
10
+ ESCAPABLE = /["\\\x00-\x1f]/
11
+ ESCAPES = (0x00..0x1f).to_h { |code| [code.chr, format("\\u%04x", code)] }
12
+ .merge("\\" => "\\\\", '"' => '\\"', "\n" => "\\n", "\r" => "\\r", "\t" => "\\t").freeze
13
+
14
+ def initialize(delimiter)
15
+ @unsafe = UNSAFE.fetch(delimiter)
16
+ end
17
+
18
+ def encode(string)
19
+ quote?(string) ? quote(string) : string
20
+ end
21
+
22
+ def key(string)
23
+ BARE_KEY.match?(string) ? string : quote(string)
24
+ end
25
+
26
+ private
27
+
28
+ def quote(string)
29
+ %("#{string.gsub(ESCAPABLE, ESCAPES)}")
30
+ end
31
+
32
+ def quote?(string)
33
+ string.empty? || READS_AS_LITERAL.match?(string) || @unsafe.match?(string)
34
+ end
35
+ end
36
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ToonFu
4
- VERSION = "0.0.1"
4
+ VERSION = "4.1.1"
5
5
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class Writer
5
+ def initialize(delimiter, indent)
6
+ @delimiter = delimiter
7
+ @marker = (delimiter == ",") ? "" : delimiter
8
+ @strings = StringLiteral.new(delimiter)
9
+ @unit = indent
10
+ @indent = ""
11
+ @hyphen = nil
12
+ @lines = []
13
+ end
14
+
15
+ def write(value)
16
+ value(value)
17
+ @lines.join("\n").force_encoding(Encoding::UTF_8)
18
+ end
19
+
20
+ private
21
+
22
+ def value(value)
23
+ case value
24
+ when Hash then mapping("", value)
25
+ when Array then array("", value)
26
+ else line(scalar(value))
27
+ end
28
+ end
29
+
30
+ def object(hash)
31
+ hash.each do |key, value|
32
+ name = @strings.key(key)
33
+ case value
34
+ when Hash then mapping(name, value)
35
+ when Array then array(name, value)
36
+ else line("#{name}: #{scalar(value)}")
37
+ end
38
+ end
39
+ end
40
+
41
+ def mapping(name, hash)
42
+ if hash.size >= 2 && (fields = Fields.of(hash.values))
43
+ line("#{name}#{header(hash.size, fields, keyed: true)}")
44
+ nested { hash.each { |key, entry| line("#{@strings.key(key)}: #{row(fields.cells(entry))}") } }
45
+ elsif name.empty?
46
+ object(hash)
47
+ else
48
+ line("#{name}:")
49
+ nested { object(hash) }
50
+ end
51
+ end
52
+
53
+ def array(name, values)
54
+ listed = name.empty? && @hyphen
55
+ if values.empty? && !listed
56
+ line(name.empty? ? "[]" : "#{name}: []")
57
+ elsif !listed && (fields = Fields.of(values))
58
+ line("#{name}#{header(values.size, fields)}")
59
+ nested { values.each { |element| line(row(fields.cells(element))) } }
60
+ elsif values.none? { |value| value.is_a?(Hash) || value.is_a?(Array) }
61
+ line("#{name}#{inline(values)}")
62
+ else
63
+ line("#{name}#{header(values.size)}")
64
+ nested { values.each { |element| item(element) } }
65
+ end
66
+ end
67
+
68
+ def item(element)
69
+ @hyphen = @indent
70
+ case element
71
+ when Array then array("", element)
72
+ when Hash then nested { object(element) }
73
+ else line(scalar(element))
74
+ end
75
+ return unless @hyphen
76
+
77
+ @lines << "#{@hyphen}-"
78
+ @hyphen = nil
79
+ end
80
+
81
+ def inline(values)
82
+ return header(values.size) if values.empty?
83
+
84
+ "#{header(values.size)} #{row(values)}"
85
+ end
86
+
87
+ def row(values)
88
+ values.map { |value| scalar(value) }.join(@delimiter)
89
+ end
90
+
91
+ def header(count, fields = nil, keyed: false)
92
+ "[#{count}#{":" if keyed}#{@marker}]#{field_list(fields) if fields}:"
93
+ end
94
+
95
+ def field_list(fields)
96
+ names = fields.columns.map { |key, nested| "#{@strings.key(key)}#{field_list(nested) if nested}" }
97
+ "{#{names.join(@delimiter)}}"
98
+ end
99
+
100
+ def line(text)
101
+ if @hyphen
102
+ @lines << "#{@hyphen}- #{text}"
103
+ @hyphen = nil
104
+ else
105
+ @lines << "#{@indent}#{text}"
106
+ end
107
+ end
108
+
109
+ def nested
110
+ outer = @indent
111
+ @indent += @unit
112
+ yield
113
+ ensure
114
+ @indent = outer
115
+ end
116
+
117
+ def scalar(value)
118
+ case value
119
+ when nil then "null"
120
+ when true, false, Integer then value.to_s
121
+ when Float then FloatLiteral.format(value)
122
+ when DecimalLiteral then value.to_s
123
+ when String then @strings.encode(value)
124
+ else raise Error, "cannot encode #{value.class}"
125
+ end
126
+ end
127
+ end
128
+ end
data/lib/toon_fu.rb CHANGED
@@ -1,8 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
4
+
3
5
  require_relative "toon_fu/version"
6
+ require_relative "toon_fu/encoder"
7
+ require_relative "toon_fu/float_literal"
8
+ require_relative "toon_fu/decimal_literal"
9
+ require_relative "toon_fu/string_literal"
10
+ require_relative "toon_fu/normalizer"
11
+ require_relative "toon_fu/fields"
12
+ require_relative "toon_fu/writer"
13
+ require_relative "toon_fu/encodable"
4
14
 
5
15
  # TOON (Token-Oriented Object Notation) for Ruby.
6
16
  module ToonFu
17
+ # Raised when a value has no TOON representation; see {Encoder#encode}.
7
18
  class Error < StandardError; end
19
+
20
+ private_constant :FloatLiteral, :DecimalLiteral, :StringLiteral, :Normalizer, :Fields, :Writer
21
+
22
+ # Encodes a value as TOON.
23
+ #
24
+ # ToonFu.encode("hello") # => "hello"
25
+ # ToonFu.encode("a,b") # => "\"a,b\""
26
+ # ToonFu.encode("a,b", delimiter: "|") # => "a,b"
27
+ # ToonFu.encode(1e-7) # => "1e-7"
28
+ # ToonFu.encode({user: {id: 1}}) # => "user:\n id: 1"
29
+ # ToonFu.encode({tags: ["a", "b"]}) # => "tags[2]: a,b"
30
+ # ToonFu.encode([{id: 1}, {id: 2}]) # => "[2]{id}:\n 1\n 2"
31
+ # ToonFu.encode({a: {x: 1}, b: {x: 2}}) # => "[2:]{x}:\n a: 1\n b: 2"
32
+ #
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
35
+ # @param options [Hash] see {Encoder#initialize}
36
+ # @return [String]
37
+ # @raise [Error] see {Encoder#encode}
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)
44
+ end
45
+
46
+ DEFAULT_ENCODER = Ractor.make_shareable(Encoder.new)
47
+ private_constant :DEFAULT_ENCODER
8
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: 0.0.1
4
+ version: 4.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin
@@ -18,17 +18,27 @@ executables: []
18
18
  extensions: []
19
19
  extra_rdoc_files: []
20
20
  files:
21
- - CHANGELOG.md
22
21
  - LICENSE
23
22
  - README.md
23
+ - lib/toon-fu.rb
24
24
  - lib/toon_fu.rb
25
+ - lib/toon_fu/decimal_literal.rb
26
+ - lib/toon_fu/encodable.rb
27
+ - lib/toon_fu/encoder.rb
28
+ - lib/toon_fu/fields.rb
29
+ - lib/toon_fu/float_literal.rb
30
+ - lib/toon_fu/normalizer.rb
31
+ - lib/toon_fu/string_literal.rb
25
32
  - lib/toon_fu/version.rb
33
+ - lib/toon_fu/writer.rb
26
34
  homepage: https://github.com/hoblin/toon-fu
27
35
  licenses:
28
36
  - MIT
29
37
  metadata:
30
38
  source_code_uri: https://github.com/hoblin/toon-fu
31
- changelog_uri: https://github.com/hoblin/toon-fu/blob/main/CHANGELOG.md
39
+ changelog_uri: https://github.com/hoblin/toon-fu/releases
40
+ documentation_uri: https://rubydoc.info/gems/toon-fu
41
+ bug_tracker_uri: https://github.com/hoblin/toon-fu/issues
32
42
  rubygems_mfa_required: 'true'
33
43
  rdoc_options: []
34
44
  require_paths:
data/CHANGELOG.md DELETED
@@ -1,5 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.0.1
4
-
5
- Name claimed. No encoder yet.