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 +4 -4
- data/README.md +165 -6
- data/lib/toon-fu.rb +3 -0
- data/lib/toon_fu/decimal_literal.rb +19 -0
- data/lib/toon_fu/encodable.rb +20 -0
- data/lib/toon_fu/encoder.rb +54 -0
- data/lib/toon_fu/fields.rb +37 -0
- data/lib/toon_fu/float_literal.rb +61 -0
- data/lib/toon_fu/normalizer.rb +116 -0
- data/lib/toon_fu/string_literal.rb +36 -0
- data/lib/toon_fu/version.rb +1 -1
- data/lib/toon_fu/writer.rb +128 -0
- data/lib/toon_fu.rb +40 -0
- metadata +13 -3
- data/CHANGELOG.md +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea42b008c3b7cb955d4121725dc71cb9d0682383adb6f082fd59db8ed5d71da8
|
|
4
|
+
data.tar.gz: 8fd5631c7400ccd2eddfa648b2b4136a74e26fcf9b89927e5d143a0782205d0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a5c4ce7f285972990ca07320cea5484de95f560be64b714f847c9b703f015700f9369f2fdfaa5599216b59e5216f991b722cc7c3cd3faf694f8160d1ff9261a
|
|
7
|
+
data.tar.gz: afceb6dbe076e69b78b8c58a2d52d0baed6279eef642efc7d6023d0290c7b795b6c84b64f3c7200acbdb9057af21804a341a4e616275bb66cee13db0035ab14b
|
data/README.md
CHANGED
|
@@ -1,19 +1,178 @@
|
|
|
1
1
|
# toon-fu
|
|
2
2
|
|
|
3
|
-
[
|
|
3
|
+
[](https://github.com/hoblin/toon-fu/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/hoblin/toon-fu/actions/workflows/spec-drift.yml)
|
|
5
|
+
[](https://rubygems.org/gems/toon-fu)
|
|
4
6
|
|
|
5
|
-
|
|
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
|
+
[](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
|
|
12
|
-
- `PATCH` is the gem's own: fixes and improvements
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
data/lib/toon_fu/version.rb
CHANGED
|
@@ -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:
|
|
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/
|
|
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:
|