toon-fu 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7f87e2144c7adb60f949127ea36ed9e26b84fb1ba5e4cd6893d9fc46f3e859e
4
- data.tar.gz: 84c74750d67f681645a7cd0e84b46675919f3e0549cc4e168306510b1f8b45bf
3
+ metadata.gz: fe7b6913ab8e4bd4224ed1b08175c8ae7b2e2a5b841c990daa77f48819f4f495
4
+ data.tar.gz: 536f77da3c7fe3ce3a34c0d7421d5777559a8a912ab6194582be07bff441e0e1
5
5
  SHA512:
6
- metadata.gz: a78c3f872ff747c74af86f91a5e945b8dcf1b2eb5d10ea9694ad2a0e3efccc938fe7652a5cfda36713163f0f39d965dbe75f1e77698bdea3aea124e81bb1b194
7
- data.tar.gz: 1b6fa5a87123368f9a7e92ee1fb1f91fce1950d583967acf007542b6ea10168c8b67c282cb87e96009841a039b657a23c77c11d4e47535d192bde41f9e82aaad
6
+ metadata.gz: be1b854a3300c9e7b70cd2fa73d2d9d6ace099c88c32d1f98a3d30152a2f2c03ab42de0f23b48bbacb6eccfa042946add466eb99dec2c7e3636cb0089857e0f8
7
+ data.tar.gz: 40cad3826f9fd815fd636fb4ac2c6b3b4e7563fefa73a9cbbfe1444161590222f914557ecaaf9d142a680b6a741cea300579e82cf5a711a943044f7adb614a83
data/README.md CHANGED
@@ -138,7 +138,7 @@ ToonFu.decode("tags[3]: a,b")
138
138
 
139
139
  ### Options
140
140
 
141
- - `strict:` — `true` (default) checks what the spec requires: declared lengths, row widths, duplicate keys, indentation. Pass `false` to read a document that bends those rules — a declared length is ignored, a duplicate key takes its last value, a stray over-indented line is skipped unless it is a bare token, which raises in either mode.
141
+ - `strict:` — `true` (default) checks everything the spec requires. Pass `false` for the spec's five recoveries and nothing more: a declared length is ignored, a duplicate key takes its last value, a tab indents one level, a blank line between an array's items or rows is skipped, and a scope's first line sets its depth. Everything else — a malformed header, a row of the wrong width, a stray over-indented line — raises in either mode.
142
142
  - `indent_size:` — spaces per nesting level, default `2`.
143
143
 
144
144
  ```ruby
@@ -166,17 +166,17 @@ b08ea2d feat: ship a toon executable
166
166
 
167
167
  ## Compared with other Ruby TOON gems
168
168
 
169
- The only one that passes every spec fixture: toon-fu passes all 160 encode and all 385 decode fixtures. The table compares encoding — the 138 encode fixtures that use default options, which every gem can run; none of the others reads TOON back.
169
+ The only one that passes every spec fixture: toon-fu passes all 160 encode and all 404 decode fixtures. The table compares encoding — the 138 encode fixtures that use default options; the other 22 set a delimiter or an indent size, which the other gems do not take, and none of them reads TOON back.
170
170
 
171
171
  | Gem | Spec fixtures passed | Speed vs toon-fu |
172
172
  |---|---:|---:|
173
173
  | **toon-fu** | **138 / 138** | **1.00×** |
174
- | sorbet-toon 0.1.0 | 104 / 138 | 0.63× |
175
- | toon-ruby 0.1.1 | 100 / 138 | 0.62× |
176
- | toon_my_json 0.1.0 | 48 / 138 | 1.62× |
177
- | toon-format 0.1.2 | 40 / 138 | 1.31× |
174
+ | sorbet-toon 0.1.0 | 104 / 138 | 0.55× |
175
+ | toon-ruby 0.1.1 | 100 / 138 | 0.55× |
176
+ | toon_my_json 0.1.0 | 46 / 138 | 1.49× |
177
+ | toon-format 0.1.2 | 40 / 138 | 1.11× |
178
178
 
179
- The Ruby TOON encoders with more than 10,000 downloads, measured by [`benchmark/run.rb`](benchmark/run.rb) on Ruby 3.4.11 (2026-10-07). **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.
179
+ The Ruby TOON encoders with more than 10,000 downloads, measured by [`benchmark/run.rb`](benchmark/run.rb) on Ruby 3.4.11 (2026-10-10). **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.
180
180
 
181
181
  What falls through the gaps:
182
182
 
@@ -203,7 +203,7 @@ The gem version tracks the TOON specification it implements:
203
203
  - `MAJOR.MINOR` is the spec version: `X.Y.Z` speaks TOON `X.Y`.
204
204
  - `PATCH` is the gem's own: fixes and improvements within the same dialect.
205
205
 
206
- This release speaks `toon-spec: 4.3`.
206
+ This release speaks `toon-spec: 4.4`.
207
207
 
208
208
  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.
209
209
 
@@ -3,13 +3,14 @@
3
3
  module ToonFu
4
4
  # Decodes TOON with one set of options, for callers that decode many.
5
5
  class Decoder
6
- # @param strict [Boolean] whether to enforce the spec's strict-mode
7
- # checks; with +false+ duplicate keys resolve last-write-wins, leading
8
- # spaces that are not a multiple of indent_size are accepted, a tab in
9
- # indentation is accepted and counts as one level, a line of spaces and
10
- # tabs is blank, a scope's first line sets the scope's depth, an
11
- # over-indented line is skipped unless it is a bare token, and a
12
- # malformed or misplaced header is read as a key-value line
6
+ # @param strict [Boolean] whether to enforce every check of the spec;
7
+ # with +false+ exactly the spec's five recoveries apply: a declared
8
+ # length is ignored; a duplicate key or a field name repeated in one
9
+ # field list resolves last-write-wins, keeping its first position; a
10
+ # tab in indentation counts as one level, leading spaces need not be a
11
+ # multiple of indent_size and a line of spaces and tabs is blank; a
12
+ # blank line inside a header span is ignored; and a scope's first line
13
+ # sets the scope's depth
13
14
  # @param indent_size [Integer] spaces per nesting level
14
15
  # @raise [ArgumentError] when strict is not a boolean or indent_size is
15
16
  # not a positive Integer
@@ -43,13 +44,14 @@ module ToonFu
43
44
  # @raise [Error] for text that is not a String or not valid UTF-8; for a
44
45
  # number outside the Float range; for an invalid escape, an
45
46
  # unterminated quoted string, or content after a closing quote; for a
46
- # key without a colon; for a bare token line inside a scope; for
47
- # content after a completed root form; for nesting beyond the depth
48
- # limit; and, when strict, for a declared length or row width that
49
- # does not match, a malformed or misplaced header, a duplicate key, a
50
- # blank line inside a header's scope, a tab in indentation, indentation
51
- # that is not a multiple of indent_size, a depth jump, and an
52
- # over-indented line, an indented first line included
47
+ # key without a colon; for a malformed or misplaced header; for a row
48
+ # width that does not match; for a line that belongs to no scope, an
49
+ # over-indented or indented first line included; for content after a
50
+ # completed root form; for nesting beyond the depth limit; and, when
51
+ # strict, for a declared length that does not match, a duplicate key, a
52
+ # field name repeated in one field list, a blank line inside a header
53
+ # span, a tab in indentation, indentation that is not a multiple of
54
+ # indent_size, and a depth jump
53
55
  def decode(text)
54
56
  Reader.new(@strict, @indent_size).read(utf8(text))
55
57
  end
@@ -21,8 +21,8 @@ module ToonFu
21
21
  end
22
22
 
23
23
  # A line whose first unquoted "[" precedes its first unquoted colon is an
24
- # array-header line; one that fails the §6 grammar is malformed, a
25
- # strict-mode error (§5.2).
24
+ # array-header line; one that fails the §6 grammar is malformed, an
25
+ # error in either mode (§5.2, §14.2).
26
26
  def malformed?
27
27
  @malformed
28
28
  end
@@ -38,7 +38,7 @@ module ToonFu
38
38
  # §6: a keyed header's field list is required, and a fields-bearing
39
39
  # header carries no inline content.
40
40
  return broken if @keyed && !fields?
41
- return broken if fields? && !@inline.strip.empty?
41
+ return broken if fields? && !Tokens.trim(@inline).empty?
42
42
 
43
43
  self
44
44
  end
data/lib/toon_fu/lines.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module ToonFu
4
4
  class Line
5
- ITEM = /\A-(?: |\z)/
5
+ ITEM = /\A-(?: +|\z)/
6
6
 
7
7
  attr_reader :content, :depth, :number, :text
8
8
 
@@ -57,10 +57,6 @@ module ToonFu
57
57
  line
58
58
  end
59
59
 
60
- def number
61
- peek ? peek.number : @lines.length
62
- end
63
-
64
60
  private
65
61
 
66
62
  def significant(text)
@@ -88,8 +84,6 @@ module ToonFu
88
84
  Line.new(line.sub(INDENTATION, ""), depth(line, number), number, line, blank)
89
85
  end
90
86
 
91
- # Non-strict mode accepts a tab as indentation (§12), where each tab
92
- # counts as one level; strict mode rejects it.
93
87
  def depth(line, number)
94
88
  indent = line[INDENTATION]
95
89
  raise Error, "cannot decode a line indented with a tab on line #{number}: #{line}" if @strict && indent.include?("\t")
@@ -25,7 +25,7 @@ module ToonFu
25
25
  return object(-1, 0) unless line.depth.zero?
26
26
  return empty_array_root if line.content == "[]"
27
27
 
28
- header = usable(Header.parse(line.content), line)
28
+ header = parse_header(line)
29
29
  return keyless_root(header) if header && header.key.nil?
30
30
  return scalar_root(line) if @lines.one? && scalar?(line)
31
31
 
@@ -70,10 +70,7 @@ module ToonFu
70
70
  end
71
71
 
72
72
  def orphan(line)
73
- raise Error, "cannot decode a line that belongs to no scope on line #{line.number}: #{line.text}" if @strict
74
- raise Error, "cannot decode a bare token line inside a scope on line #{line.number}: #{line.text}" if scalar?(line)
75
-
76
- @lines.next
73
+ raise Error, "cannot decode a line that belongs to no scope on line #{line.number}: #{line.text}"
77
74
  end
78
75
 
79
76
  def scalar?(line)
@@ -87,12 +84,12 @@ module ToonFu
87
84
  end
88
85
 
89
86
  def field(line, depth)
90
- header = usable(Header.parse(line.content), line)
87
+ header = parse_header(line)
91
88
  return [Tokens.key(header.key), header_value(header, depth)] if header&.key
92
89
  misplaced(line, "a keyless array header in object field position") if header
93
90
 
94
91
  colon = Tokens.colon_index(line.content)
95
- raise Error, "cannot decode a line without a colon after its key on line #{line.number}: #{line.text}" if colon.nil?
92
+ raise Error, "cannot decode a line without a colon on line #{line.number}: #{line.text}" if colon.nil?
96
93
 
97
94
  key = Tokens.key(line.content[0...colon])
98
95
  [key, field_value(Tokens.trim(line.content[(colon + 1)..]), depth)]
@@ -135,9 +132,9 @@ module ToonFu
135
132
  return {} if rest.empty?
136
133
  return [] if rest == "[]"
137
134
 
138
- header = usable(Header.parse(rest), line)
135
+ header = parse_header(line, rest)
139
136
  return item_header(header, line) if header
140
- return item_object(rest, line.depth) if Tokens.colon_index(rest)
137
+ return item_object(rest, line) if Tokens.colon_index(rest)
141
138
 
142
139
  Token.decode(Tokens.trim(rest))
143
140
  end
@@ -146,19 +143,20 @@ module ToonFu
146
143
  return array(header, line.depth) unless header.key || header.fields?
147
144
 
148
145
  misplaced(line, "a keyless fields-bearing header as a list item") unless header.key
149
- item_object(line.item_content, line.depth)
146
+ item_object(line.item_content, line)
150
147
  end
151
148
 
152
149
  def misplaced(line, what)
153
- raise Error, "cannot decode #{what} on line #{line.number}: #{line.text}" if @strict
150
+ raise Error, "cannot decode #{what} on line #{line.number}: #{line.text}"
154
151
  end
155
152
 
156
- def item_object(rest, depth)
157
- synthetic = Line.new(rest, depth + 1, @lines.number)
158
- key, value = descend { field(synthetic, depth + 1) }
153
+ def item_object(rest, line)
154
+ depth = line.depth + 1
155
+ synthetic = Line.new(rest, depth, line.number)
156
+ key, value = descend { field(synthetic, depth) }
159
157
  result = {}
160
158
  store(result, key, value, synthetic)
161
- descend { object(depth, depth + 1, result) }
159
+ descend { object(line.depth, depth, result) }
162
160
  end
163
161
 
164
162
  def table(header, parent)
@@ -190,15 +188,11 @@ module ToonFu
190
188
  collected = descend do
191
189
  gather(parent) do |line|
192
190
  colon = Tokens.colon_index(line.content)
193
- if colon.nil?
194
- raise Error, "cannot decode an entry row without a colon on line #{line.number}: #{line.text}" if @strict
191
+ raise Error, "cannot decode an entry row without a colon on line #{line.number}: #{line.text}" if colon.nil?
195
192
 
196
- next nil
197
- end
198
193
  [Tokens.key(line.content[0...colon]), row(header, line, line.content[(colon + 1)..]), line]
199
194
  end
200
195
  end
201
- collected.compact!
202
196
  count(collected.length, header, "entry row")
203
197
  collected.each { |key, value, line| store(result, key, value, line) }
204
198
  result
@@ -209,10 +203,7 @@ module ToonFu
209
203
  collected = []
210
204
  depth = content_depth(parent)
211
205
  while depth && (line = @lines.peek) && line.depth > parent
212
- if line.depth != depth
213
- orphan(line)
214
- next
215
- end
206
+ orphan(line) if line.depth != depth
216
207
  break if stop&.call(line)
217
208
 
218
209
  take
@@ -233,21 +224,12 @@ module ToonFu
233
224
  cells = Tokens.split(text, header.delimiter)
234
225
  cells = [] if cells.length == 1 && Tokens.trim(cells.first).empty?
235
226
  width(cells.length, header, line)
236
- shape(header.fields, cells.each)
227
+ shape(header.fields, cells)
237
228
  end
238
229
 
239
230
  def shape(fields, cells)
240
231
  fields.each_with_object({}) do |(name, nested), result|
241
- if nested
242
- result[name] = shape(nested, cells)
243
- else
244
- cell = begin
245
- cells.next
246
- rescue StopIteration
247
- next
248
- end
249
- result[name] = Token.decode(Tokens.trim(cell))
250
- end
232
+ result[name] = nested ? shape(nested, cells) : Token.decode(Tokens.trim(cells.shift))
251
233
  end
252
234
  end
253
235
 
@@ -258,16 +240,16 @@ module ToonFu
258
240
  end
259
241
 
260
242
  def width(actual, header, line)
261
- return unless @strict && actual != header.leaves.length
243
+ return if actual == header.leaves.length
262
244
 
263
245
  raise Error, "cannot decode #{actual} cells on line #{line.number} where the header declares #{header.leaves.length} field#{"s" unless header.leaves.length == 1}"
264
246
  end
265
247
 
266
- def usable(header, line)
267
- return header unless header&.malformed?
268
- raise Error, "cannot decode a malformed array header on line #{line.number}: #{line.text}" if @strict
248
+ def parse_header(line, content = line.content)
249
+ header = Header.parse(content)
250
+ raise Error, "cannot decode a malformed array header on line #{line.number}: #{line.text}" if header&.malformed?
269
251
 
270
- nil
252
+ header
271
253
  end
272
254
 
273
255
  def content_depth(parent)
@@ -3,7 +3,7 @@
3
3
  module ToonFu
4
4
  module Tokens
5
5
  QUOTE_SPAN = /"(?:[^"\\]|\\.?)*"?/
6
- TRAILING_WHITESPACE = /[[:space:]]\z/
6
+ TRAILING_WHITESPACE = /[ \t]\z/
7
7
  UNTIL_COLON = /[^":]+/
8
8
  UNTIL_BRACKET = /[^":\[]+/
9
9
  UNTIL_BRACE = /[^"{]+/
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ToonFu
4
- VERSION = "4.3.0"
4
+ VERSION = "4.4.0"
5
5
  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.3.0
4
+ version: 4.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin