toon-fu 4.1.2 → 4.1.4

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: e095746e545c92bf6411c36b2042dde6d305deb9a1363d500d48877f09939b63
4
- data.tar.gz: 937a2796b6a6f04afa2469994c94527473e93c66f575747adefea369691628d4
3
+ metadata.gz: 4a5a734c469d08f497c36883702683c8a1c7edc3245df9f886d5f6159ff26db1
4
+ data.tar.gz: 784178fff8819a4895e5252b8e356ffaddaceca1ff10d9d6067a73984d5ef8cc
5
5
  SHA512:
6
- metadata.gz: 979b0210dfed7025dc7be6ba5c32362d7293ea394cb9fbf495374a17d136a9e81d9f8af0419278b891d39bde60c731624050efd4bf5c6ee3dfcc7858d7347b5e
7
- data.tar.gz: 481896ac9ff0f47a297e794e90ebd3aae04d3dfbe1267a0e5b8c264f5ef4385a1816b0d241d0bd2ace3047d7748d9b4eefa2487cc251b3f264c8054967552d2c
6
+ metadata.gz: 4acf57be096b69fa1c675a97efb59fe63db7b66234e649305f5cb1dafa319465fa3ffb235d2aa252dd714861476720a910b7859e6916dc6ba45584b1d75a886c
7
+ data.tar.gz: 3ba5aa86ccfae68c53da20cfaa66a31d3ddee6b5e57af7c72f3ef884d36be45fe485b234973cd6cef2b31d1751d482d1f36b20a9d5eff746f02ff486ccc997e3
data/README.md CHANGED
@@ -4,11 +4,11 @@
4
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
5
  [![Gem](https://img.shields.io/gem/v/toon-fu)](https://rubygems.org/gems/toon-fu)
6
6
 
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.
7
+ [TOON](https://toonformat.dev/) (Token-Oriented Object Notation) codec for Ruby, versioned by the spec it implements: the gem's `MAJOR.MINOR` is the TOON spec version it speaks.
8
8
 
9
9
  ## What is this?
10
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.
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 and decode fixture of the spec version it implements passes.
12
12
 
13
13
  ## Why?
14
14
 
@@ -111,9 +111,62 @@ For ActiveRecord models, pass `record.as_json` (or define `as_toon`).
111
111
 
112
112
  Everything else raises `ToonFu::Error` — including a `Struct` or `Data` without `as_toon`, circular references, and nesting too deep for the stack.
113
113
 
114
+ ## Reading TOON
115
+
116
+ ```ruby
117
+ ToonFu.decode("users[2]{id,name}:\n 1,Ada\n 2,Bob")
118
+ # => {"users" => [{"id" => 1, "name" => "Ada"}, {"id" => 2, "name" => "Bob"}]}
119
+ ```
120
+
121
+ You get back plain Ruby: `Hash` with String keys, `Array`, `String`, `Integer`, `Float`, `true`, `false`, `nil`.
122
+
123
+ Quoting decides the type, and an unquoted token is a number only if the spec says it is one:
124
+
125
+ ```ruby
126
+ ToonFu.decode("a: 42") # => {"a" => 42}
127
+ ToonFu.decode('a: "42"') # => {"a" => "42"}
128
+ ToonFu.decode("a: 05") # => {"a" => "05"}
129
+ ToonFu.decode("a: +1") # => {"a" => "+1"}
130
+ ```
131
+
132
+ Malformed input raises `ToonFu::Error`:
133
+
134
+ ```ruby
135
+ ToonFu.decode("tags[3]: a,b")
136
+ # ToonFu::Error: cannot decode 2 values where the header declares 3
137
+ ```
138
+
139
+ ### Options
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.
142
+ - `indent_size:` — spaces per nesting level, default `2`.
143
+
144
+ ```ruby
145
+ ToonFu.decode(text, strict: false)
146
+ decoder = ToonFu::Decoder.new(indent_size: 4) # reuse for many documents
147
+ decoder.decode(text)
148
+ ```
149
+
150
+ ## On the command line
151
+
152
+ The gem installs a `toon` executable that encodes JSON on stdin — handy for reading a command's output in fewer tokens:
153
+
154
+ ```bash
155
+ $ gh api repos/ruby/ruby/pulls --jq '.[0] | {number, title}' | toon
156
+ number: 13579
157
+ title: Fix a typo
158
+ ```
159
+
160
+ Input it cannot encode is passed through unchanged, so putting it at the end of a pipe never costs you the output:
161
+
162
+ ```bash
163
+ $ git log --oneline -1 | toon
164
+ b08ea2d feat: ship a toon executable
165
+ ```
166
+
114
167
  ## Compared with other Ruby TOON gems
115
168
 
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.
169
+ The only one that passes every spec fixture: toon-fu passes all 179 encode and all 359 decode fixtures. The table compares encoding — the 154 encode fixtures that use default options, which every gem can run; none of the others reads TOON back.
117
170
 
118
171
  | Gem | Spec fixtures passed | Speed vs toon-fu |
119
172
  |---|---:|---:|
@@ -150,6 +203,8 @@ The gem version tracks the TOON specification it implements:
150
203
  - `MAJOR.MINOR` is the spec version: `X.Y.Z` speaks TOON `X.Y`.
151
204
  - `PATCH` is the gem's own: fixes and improvements within the same dialect.
152
205
 
206
+ This release speaks `toon-spec: 4.1`.
207
+
153
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.
154
209
 
155
210
  Release notes: [GitHub releases](https://github.com/hoblin/toon-fu/releases).
data/exe/toon ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "toon_fu"
6
+
7
+ input = $stdin.read
8
+
9
+ puts(begin
10
+ ToonFu.encode(JSON.parse(input))
11
+ rescue
12
+ input
13
+ end)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ # Decodes TOON with one set of options, for callers that decode many.
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, and a
9
+ # tab in indentation is accepted and counts as one level
10
+ # @param indent_size [Integer] spaces per nesting level
11
+ # @raise [ArgumentError] when strict is not a boolean or indent_size is
12
+ # not a positive Integer
13
+ def initialize(strict: true, indent_size: 2)
14
+ raise ArgumentError, "strict must be true or false, got #{strict.inspect}" unless [true, false].include?(strict)
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
+ @strict = strict
18
+ @indent_size = indent_size
19
+ end
20
+
21
+ # Decodes TOON into the JSON data model.
22
+ #
23
+ # Returns +Hash+ with String keys, +Array+, +String+, +Integer+,
24
+ # +Float+, +true+, +false+ or +nil+. An unquoted token is a number only
25
+ # when it matches the spec's own grammar, so +.5+, +1.+, +05+, +Infinity+
26
+ # and +1_000+ decode as strings.
27
+ #
28
+ # Out-of-range numbers: a token the grammar accepts but +Float+ cannot
29
+ # hold without losing its magnitude, such as +1e400+ or +1e-400+, raises
30
+ # rather than becoming infinity or zero. Plain digits become an exact
31
+ # +Integer+ of any size; a fraction or an exponent goes through +Float+
32
+ # and carries its 53-bit precision, even when the result is integral.
33
+ #
34
+ # Key order is the document's, except in the tabular and keyed tabular
35
+ # forms, where it is the header's field order at every level. Nesting
36
+ # deeper than 100 levels raises rather than exhausting the stack.
37
+ #
38
+ # @param text [String] UTF-8, or an encoding that transcodes to it
39
+ # @return [Hash, Array, String, Integer, Float, true, false, nil]
40
+ # @raise [Error] for text that is not a String or not valid UTF-8; for a
41
+ # number outside the Float range; for an invalid escape, an
42
+ # unterminated quoted string, or content after a closing quote; for a
43
+ # key without a colon; for a bare token line inside a scope; for
44
+ # content after a completed root form; for nesting beyond the depth
45
+ # limit; and, when strict, for a declared length or row width that
46
+ # does not match, a malformed header, a duplicate key, a blank line
47
+ # inside a header's scope, and indentation that is not a multiple of
48
+ # indent_size
49
+ def decode(text)
50
+ Reader.new(@strict, @indent_size).read(utf8(text))
51
+ end
52
+
53
+ private
54
+
55
+ def utf8(text)
56
+ raise Error, "cannot decode #{text.class}; pass a String" unless text.is_a?(String)
57
+
58
+ text = text.dup.force_encoding(Encoding::UTF_8) if text.encoding == Encoding::BINARY
59
+ text = text.encode(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
60
+ raise Error, "cannot decode text that is not valid UTF-8" unless text.valid_encoding?
61
+
62
+ text
63
+ rescue EncodingError => error
64
+ raise Error, "cannot decode text as UTF-8: #{error.message}"
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class FieldList
5
+ def self.parse(text, delimiter)
6
+ new(text, delimiter).parse
7
+ end
8
+
9
+ def initialize(text, delimiter)
10
+ @text = text
11
+ @delimiter = delimiter
12
+ end
13
+
14
+ def parse
15
+ return nil if @text.empty?
16
+
17
+ entries = []
18
+ Tokens.split_fields(@text, @delimiter).each do |entry|
19
+ return nil if foreign?(entry)
20
+
21
+ name, nested = split(entry)
22
+ return nil if name.nil?
23
+
24
+ entries << [name, nested]
25
+ end
26
+ entries
27
+ end
28
+
29
+ private
30
+
31
+ # A field list is split by the bracket segment's delimiter alone, so
32
+ # another delimiter left unquoted in an entry is a header defect (§6).
33
+ # Quoting protects it, which is how the encoder emits {id,"a|b"}.
34
+ def foreign?(entry)
35
+ others = Encoder::DELIMITERS - [@delimiter]
36
+ Tokens.index_of(entry, Tokens::UNTIL_FOREIGN.fetch(@delimiter), others.join) ? true : false
37
+ end
38
+
39
+ def split(entry)
40
+ brace = Tokens.brace_index(entry)
41
+ return [Tokens.key(entry), nil] if brace.nil?
42
+
43
+ name = entry[0...brace]
44
+ group = entry[brace..]
45
+ closing = Tokens.closing_brace(group)
46
+ return [nil, nil] unless closing == group.length - 1
47
+
48
+ nested = self.class.parse(group[1...closing], @delimiter)
49
+ nested.nil? ? [nil, nil] : [Tokens.key(name), nested]
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class Header
5
+ LENGTH = /\A(?:0|[1-9][0-9]*)\z/
6
+ DELIMITERS = {"" => ",", "\t" => "\t", "|" => "|"}.freeze
7
+
8
+ attr_reader :key, :length, :delimiter, :fields, :inline
9
+
10
+ def self.parse(content)
11
+ bracket = Tokens.bracket_index(content)
12
+ return nil if bracket.nil?
13
+
14
+ new(content, bracket).parse
15
+ end
16
+
17
+ def initialize(content, bracket)
18
+ @content = content
19
+ @bracket = bracket
20
+ @malformed = false
21
+ end
22
+
23
+ # A line carrying a bracket segment is either a header, or an attempt at
24
+ # one that §6 makes a strict-mode error; only a line without one falls
25
+ # through to the key-value class untouched.
26
+ def malformed?
27
+ @malformed
28
+ end
29
+
30
+ def parse
31
+ @key = @bracket.zero? ? nil : @content[0...@bracket]
32
+ return nil unless @key.nil? || Tokens.whole_token?(@key)
33
+ return broken if @key&.match?(/\s\z/)
34
+ return broken unless (closing = @content.index("]", @bracket))
35
+
36
+ return broken unless segment(@content[(@bracket + 1)...closing])
37
+ return broken unless tail(closing + 1)
38
+ # §6: a keyed header's field list is required, and a fields-bearing
39
+ # header carries no inline content.
40
+ return broken if @keyed && !fields?
41
+ return broken if fields? && !@inline.strip.empty?
42
+
43
+ self
44
+ end
45
+
46
+ def broken
47
+ @malformed = true
48
+ self
49
+ end
50
+
51
+ def keyed?
52
+ @keyed
53
+ end
54
+
55
+ def fields?
56
+ !@fields.nil?
57
+ end
58
+
59
+ def leaves
60
+ @leaves ||= walk(@fields)
61
+ end
62
+
63
+ # Names repeated across nesting levels are not duplicates (§9.3): only a
64
+ # repeat within one list counts.
65
+ def duplicate
66
+ return @duplicate if defined?(@duplicate)
67
+
68
+ @duplicate = repeated(@fields)
69
+ end
70
+
71
+ private
72
+
73
+ def segment(text)
74
+ @keyed = false
75
+ if text.end_with?(":")
76
+ @keyed = true
77
+ text = text[0...-1]
78
+ elsif (marker = text[-1]) && DELIMITERS.key?(marker) && !marker.empty?
79
+ @delimiter = DELIMITERS.fetch(marker)
80
+ text = text[0...-1]
81
+ if text.end_with?(":")
82
+ @keyed = true
83
+ text = text[0...-1]
84
+ end
85
+ end
86
+ @delimiter ||= ","
87
+ return false unless LENGTH.match?(text)
88
+
89
+ @length = Integer(text, 10)
90
+ true
91
+ end
92
+
93
+ def tail(index)
94
+ rest = @content[index..]
95
+ if rest.start_with?("{")
96
+ closing = Tokens.closing_brace(rest)
97
+ return false if closing.nil?
98
+
99
+ @fields = FieldList.parse(rest[1...closing], @delimiter)
100
+ return false if @fields.nil?
101
+
102
+ rest = rest[(closing + 1)..]
103
+ end
104
+ return false unless rest.start_with?(":")
105
+
106
+ @inline = rest[1..]
107
+ true
108
+ end
109
+
110
+ def walk(fields)
111
+ fields.flat_map { |name, nested| nested ? walk(nested) : [name] }
112
+ end
113
+
114
+ def repeated(fields)
115
+ names = fields.map(&:first)
116
+ duplicate = names.tally.find { |_, count| count > 1 }
117
+ return duplicate.first if duplicate
118
+
119
+ fields.filter_map { |_, nested| nested && repeated(nested) }.first
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class Line
5
+ ITEM = /\A-(?: |\z)/
6
+
7
+ attr_reader :content, :depth, :number, :text
8
+
9
+ attr_reader :after_blank
10
+
11
+ def initialize(content, depth, number, text = content, after_blank = false)
12
+ @content = content
13
+ @depth = depth
14
+ @number = number
15
+ @text = text
16
+ @after_blank = after_blank
17
+ end
18
+
19
+ def item?
20
+ ITEM.match?(@content)
21
+ end
22
+
23
+ def item_content
24
+ @content.sub(ITEM, "")
25
+ end
26
+ end
27
+
28
+ class Lines
29
+ BOM = "\u{feff}"
30
+ COMMENT = /\A *#/
31
+ TRAILING_SPACES = / +\z/
32
+ BLANK = /\A\s*\z/
33
+ INDENTATION = /\A[ \t]*/
34
+
35
+ def initialize(text, strict, indent_size)
36
+ @strict = strict
37
+ @indent_size = indent_size
38
+ @lines = significant(text.delete_prefix(BOM))
39
+ @index = 0
40
+ end
41
+
42
+ def empty?
43
+ @index >= @lines.length
44
+ end
45
+
46
+ def one?
47
+ @lines.length - @index == 1
48
+ end
49
+
50
+ def peek
51
+ @lines[@index]
52
+ end
53
+
54
+ def next
55
+ line = @lines[@index]
56
+ @index += 1
57
+ line
58
+ end
59
+
60
+ def number
61
+ peek ? peek.number : @lines.length
62
+ end
63
+
64
+ private
65
+
66
+ def significant(text)
67
+ blank = false
68
+ text.split("\n", -1).each_with_index.filter_map do |raw, offset|
69
+ line = raw.delete_suffix("\r").sub(TRAILING_SPACES, "")
70
+ next if COMMENT.match?(line)
71
+
72
+ if BLANK.match?(line)
73
+ blank = true
74
+ next
75
+ end
76
+
77
+ built = build(line, offset + 1, blank)
78
+ blank = false
79
+ built
80
+ end
81
+ end
82
+
83
+ def build(line, number, blank)
84
+ Line.new(line.sub(INDENTATION, ""), depth(line, number), number, line, blank)
85
+ end
86
+
87
+ # Non-strict mode accepts a tab as indentation (§12), where each tab
88
+ # counts as one level; strict mode rejects it.
89
+ def depth(line, number)
90
+ indent = line[INDENTATION]
91
+ raise Error, "cannot decode a line indented with a tab on line #{number}: #{line}" if @strict && indent.include?("\t")
92
+
93
+ spaces = indent.count(" ")
94
+ raise Error, "cannot decode an indentation of #{spaces} spaces on line #{number}, not a multiple of #{@indent_size}: #{line}" if @strict && !(spaces % @indent_size).zero?
95
+
96
+ spaces / @indent_size + indent.count("\t")
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class Reader
5
+ MAX_DEPTH = 100
6
+
7
+ def initialize(strict, indent_size)
8
+ @strict = strict
9
+ @indent_size = indent_size
10
+ @depth = 0
11
+ end
12
+
13
+ def read(text)
14
+ @lines = Lines.new(text, @strict, @indent_size)
15
+ return {} if @lines.empty?
16
+
17
+ root
18
+ end
19
+
20
+ private
21
+
22
+ def root
23
+ line = @lines.peek
24
+ return empty_array_root if line.content == "[]"
25
+
26
+ header = usable(Header.parse(line.content), line)
27
+ return keyless_root(header) if header && header.key.nil?
28
+ return scalar_root(line) if @lines.one? && Tokens.colon_index(line.content).nil?
29
+
30
+ object(0)
31
+ end
32
+
33
+ def keyless_root(header)
34
+ @lines.next
35
+ value = header.fields? ? table(header, 1) : array(header, 1)
36
+ trailing
37
+ value
38
+ end
39
+
40
+ def empty_array_root
41
+ @lines.next
42
+ trailing
43
+ []
44
+ end
45
+
46
+ def scalar_root(line)
47
+ @lines.next
48
+ Token.decode(Tokens.trim(line.content))
49
+ end
50
+
51
+ def trailing
52
+ return if @lines.empty?
53
+
54
+ raise Error, "cannot decode content after the root form: #{@lines.peek.text}"
55
+ end
56
+
57
+ def object(depth, result = {})
58
+ while (line = @lines.peek) && line.depth >= depth
59
+ if line.depth > depth
60
+ orphan(line)
61
+ next
62
+ end
63
+
64
+ @lines.next
65
+ key, value = field(line, depth)
66
+ store(result, key, value, line)
67
+ end
68
+ result
69
+ end
70
+
71
+ def orphan(line)
72
+ raise Error, "cannot decode a line that belongs to no scope on line #{line.number}: #{line.text}" if @strict
73
+
74
+ @lines.next
75
+ end
76
+
77
+ def store(result, key, value, line)
78
+ raise Error, "cannot decode a duplicate key on line #{line.number}: #{key.inspect}" if @strict && result.key?(key)
79
+
80
+ result[key] = value
81
+ end
82
+
83
+ def field(line, depth)
84
+ header = usable(Header.parse(line.content), line)
85
+ return [Tokens.key(header.key), header_value(header, depth)] if header&.key
86
+ raise Error, "cannot decode a keyless array header in object field position on line #{line.number}: #{line.text}" if header
87
+
88
+ colon = Tokens.colon_index(line.content)
89
+ raise Error, "cannot decode a line without a colon after its key on line #{line.number}: #{line.text}" if colon.nil?
90
+
91
+ key = Tokens.key(line.content[0...colon])
92
+ [key, field_value(Tokens.trim(line.content[(colon + 1)..]), depth)]
93
+ end
94
+
95
+ def field_value(text, depth)
96
+ return [] if text == "[]"
97
+ return nested(depth) if text.empty?
98
+
99
+ Token.decode(text)
100
+ end
101
+
102
+ def nested(depth)
103
+ line = @lines.peek
104
+ return {} if line.nil? || line.depth <= depth
105
+
106
+ guard(line)
107
+ descend { object(depth + 1) }
108
+ end
109
+
110
+ def header_value(header, depth)
111
+ return table(header, depth + 1) if header.fields?
112
+ return [] if header.length.zero? && !items?(depth + 1)
113
+
114
+ array(header, depth + 1)
115
+ end
116
+
117
+ def items?(depth)
118
+ line = @lines.peek
119
+ !line.nil? && line.depth == depth
120
+ end
121
+
122
+ def array(header, depth)
123
+ return inline(header) unless header.inline.empty?
124
+
125
+ descend { list(header, depth) }
126
+ end
127
+
128
+ def inline(header)
129
+ cells = Tokens.split(header.inline, header.delimiter).map { |cell| Token.decode(Tokens.trim(cell)) }
130
+ count(cells.length, header, "value")
131
+ cells
132
+ end
133
+
134
+ def list(header, depth)
135
+ items = []
136
+ while (line = @lines.peek) && line.depth == depth && line.item?
137
+ span(line) unless items.empty?
138
+ @lines.next
139
+ items << item(line, depth)
140
+ end
141
+ scalar_line(@lines.peek, depth)
142
+ count(items.length, header, "item")
143
+ items
144
+ end
145
+
146
+ # A scalar line is valid only as a root primitive; inside a scope it is a
147
+ # structural error in strict and non-strict mode alike (§5.2, §14.2).
148
+ def scalar_line(line, depth)
149
+ return if line.nil? || line.depth != depth || Tokens.colon_index(line.content)
150
+
151
+ header = Header.parse(line.content)
152
+ return if header && !header.malformed?
153
+
154
+ raise Error, "cannot decode a bare token line inside a scope on line #{line.number}: #{line.text}"
155
+ end
156
+
157
+ def item(line, depth)
158
+ rest = line.item_content
159
+ return {} if rest.empty?
160
+ return [] if rest == "[]"
161
+
162
+ header = usable(Header.parse(rest), line)
163
+ return item_header(header, line, depth) if header
164
+ return item_object(rest, depth) if Tokens.colon_index(rest)
165
+
166
+ Token.decode(Tokens.trim(rest))
167
+ end
168
+
169
+ def item_header(header, line, depth)
170
+ return item_object(line.item_content, depth) if header.key
171
+ raise Error, "cannot decode a keyless fields-bearing header as a list item on line #{line.number}: #{line.text}" if header.fields?
172
+
173
+ array(header, depth + 1)
174
+ end
175
+
176
+ def item_object(rest, depth)
177
+ synthetic = Line.new(rest, depth + 1, @lines.number)
178
+ key, value = descend { field(synthetic, depth + 1) }
179
+ result = {}
180
+ store(result, key, value, synthetic)
181
+ following = @lines.peek
182
+ span(following) if following && following.depth == depth + 1
183
+ descend { object(depth + 1, result) }
184
+ end
185
+
186
+ def table(header, depth)
187
+ raise Error, "cannot decode a field name repeated in one field list: #{header.duplicate.inspect}" if @strict && header.duplicate
188
+
189
+ header.keyed? ? entries(header, depth) : rows(header, depth)
190
+ end
191
+
192
+ def rows(header, depth)
193
+ collected = descend do
194
+ gather(depth, stop: ->(line) { key_value_line?(line, header.delimiter) }) { |line| row(header, line) }
195
+ end
196
+ count(collected.length, header, "row")
197
+ collected
198
+ end
199
+
200
+ # §9.3: at row depth a line whose first unquoted colon precedes its first
201
+ # unquoted delimiter is a key-value line, and the rows end before it.
202
+ def key_value_line?(line, delimiter)
203
+ colon = Tokens.colon_index(line.content)
204
+ return false if colon.nil?
205
+
206
+ cell = Tokens.index_of(line.content, Tokens::UNTIL_CELL_END.fetch(delimiter), delimiter)
207
+ cell.nil? || colon < cell
208
+ end
209
+
210
+ def entries(header, depth)
211
+ result = {}
212
+ collected = descend do
213
+ gather(depth) do |line|
214
+ colon = Tokens.colon_index(line.content)
215
+ if colon.nil?
216
+ raise Error, "cannot decode an entry row without a colon on line #{line.number}: #{line.text}" if @strict
217
+
218
+ next nil
219
+ end
220
+ [Tokens.key(line.content[0...colon]), row(header, line, line.content[(colon + 1)..]), line]
221
+ end
222
+ end
223
+ collected.compact!
224
+ count(collected.length, header, "entry row")
225
+ collected.each { |key, value, line| store(result, key, value, line) }
226
+ result
227
+ end
228
+
229
+ def gather(depth, stop: nil)
230
+ collected = []
231
+ while (line = @lines.peek) && line.depth == depth
232
+ break if stop&.call(line)
233
+
234
+ span(line) unless collected.empty?
235
+ @lines.next
236
+ collected << yield(line)
237
+ end
238
+ collected
239
+ end
240
+
241
+ def span(line)
242
+ return unless @strict && line.after_blank
243
+
244
+ raise Error, "cannot decode a blank line inside a header span, before line #{line.number}: #{line.text}"
245
+ end
246
+
247
+ def row(header, line, text = line.content)
248
+ cells = Tokens.split(text, header.delimiter)
249
+ cells = [] if cells.length == 1 && Tokens.trim(cells.first).empty?
250
+ width(cells.length, header, line)
251
+ shape(header.fields, cells.each)
252
+ end
253
+
254
+ def shape(fields, cells)
255
+ fields.each_with_object({}) do |(name, nested), result|
256
+ if nested
257
+ result[name] = shape(nested, cells)
258
+ else
259
+ cell = begin
260
+ cells.next
261
+ rescue StopIteration
262
+ next
263
+ end
264
+ result[name] = Token.decode(Tokens.trim(cell))
265
+ end
266
+ end
267
+ end
268
+
269
+ def count(actual, header, noun)
270
+ return unless @strict && actual != header.length
271
+
272
+ raise Error, "cannot decode #{actual} #{noun}#{"s" unless actual == 1} where the header declares #{header.length}"
273
+ end
274
+
275
+ def width(actual, header, line)
276
+ return unless @strict && actual != header.leaves.length
277
+
278
+ raise Error, "cannot decode #{actual} cells on line #{line.number} where the header declares #{header.leaves.length} field#{"s" unless header.leaves.length == 1}"
279
+ end
280
+
281
+ def usable(header, line)
282
+ return header unless header&.malformed?
283
+ raise Error, "cannot decode a malformed array header on line #{line.number}: #{line.text}" if @strict
284
+
285
+ nil
286
+ end
287
+
288
+ def guard(line)
289
+ return unless @strict && line.depth > @depth + 1
290
+
291
+ raise Error, "cannot decode a depth jump on line #{line.number}: #{line.text}"
292
+ end
293
+
294
+ def descend
295
+ @depth += 1
296
+ raise Error, "cannot decode nesting deeper than #{MAX_DEPTH} levels" if @depth > MAX_DEPTH
297
+
298
+ yield
299
+ ensure
300
+ @depth -= 1
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ class Token
5
+ NUMBER = /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?\z/i
6
+ INTEGER = /\A-?(?:0|[1-9][0-9]*)\z/
7
+ ESCAPE = /\\(?:(["\\nrt])|u([0-9a-fA-F]{4})|(.|\z))/m
8
+ UNESCAPES = {'"' => '"', "\\" => "\\", "n" => "\n", "r" => "\r", "t" => "\t"}.freeze
9
+ SURROGATES = (0xd800..0xdfff)
10
+
11
+ def self.key(text)
12
+ text.start_with?('"') ? quoted(text) : text
13
+ end
14
+
15
+ def self.decode(text)
16
+ return quoted(text) if text.start_with?('"')
17
+ return nil if text == "null"
18
+ return true if text == "true"
19
+ return false if text == "false"
20
+ return number(text) if NUMBER.match?(text)
21
+
22
+ text
23
+ end
24
+
25
+ def self.quoted(text)
26
+ closing = closing_quote(text)
27
+ raise Error, "cannot decode an unterminated quoted string: #{text}" if closing.nil?
28
+ raise Error, "cannot decode content after a closing quote: #{text}" unless closing == text.length - 1
29
+
30
+ unescape(text[1...closing])
31
+ end
32
+
33
+ def self.closing_quote(text)
34
+ index = 1
35
+ while index < text.length
36
+ case text[index]
37
+ when "\\" then index += 2
38
+ when '"' then return index
39
+ else index += 1
40
+ end
41
+ end
42
+ nil
43
+ end
44
+
45
+ def self.unescape(body)
46
+ body.gsub(ESCAPE) do
47
+ if (literal = UNESCAPES[$1])
48
+ literal
49
+ elsif $2
50
+ codepoint($2.hex)
51
+ else
52
+ raise Error, "cannot decode the escape sequence \\#{$3}"
53
+ end
54
+ end
55
+ end
56
+
57
+ def self.codepoint(value)
58
+ raise Error, "cannot decode the lone surrogate escape \\u#{format("%04x", value)}" if SURROGATES.cover?(value)
59
+
60
+ value.chr(Encoding::UTF_8)
61
+ end
62
+
63
+ def self.number(text)
64
+ return Integer(text, 10) if INTEGER.match?(text)
65
+
66
+ float = Float(text)
67
+ raise Error, "cannot decode #{text} as a Float without losing its magnitude" if float.infinite? || (float.zero? && nonzero?(text))
68
+
69
+ float.zero? ? 0.0 : float
70
+ end
71
+
72
+ def self.nonzero?(text)
73
+ text.split(/e/i).first.match?(/[1-9]/)
74
+ end
75
+
76
+ private_class_method :quoted, :unescape, :codepoint, :number, :nonzero?
77
+ end
78
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonFu
4
+ module Tokens
5
+ QUOTED = /"(?:[^"\\]|\\.)*"/
6
+ UNTIL_COLON = /[^":]+/
7
+ UNTIL_BRACKET = /[^":\[]+/
8
+ UNTIL_BRACE = /[^"{]+/
9
+ BRACE = /[{}]/
10
+ SURROUNDING_SPACES = /\A +| +\z/
11
+ UNTIL_CELL_END = Encoder::DELIMITERS.to_h { |d| [d, /[^"#{Regexp.escape(d)}]+/] }.freeze
12
+ UNTIL_FIELD_END = Encoder::DELIMITERS.to_h { |d| [d, /[^"{}#{Regexp.escape(d)}]+/] }.freeze
13
+ UNTIL_FOREIGN = Encoder::DELIMITERS.to_h { |d| [d, /[^"#{Regexp.escape((Encoder::DELIMITERS - [d]).join)}]+/] }.freeze
14
+
15
+ def self.index_of(text, pattern, stops)
16
+ scanner = StringScanner.new(text)
17
+ until scanner.eos?
18
+ next if scanner.skip(pattern)
19
+ return scanner.charpos if stops.include?(scanner.peek(1))
20
+ return nil unless scanner.skip(QUOTED)
21
+ end
22
+ nil
23
+ end
24
+
25
+ def self.colon_index(text)
26
+ index_of(text, UNTIL_COLON, ":")
27
+ end
28
+
29
+ def self.bracket_index(text)
30
+ index = index_of(text, UNTIL_BRACKET, "[:")
31
+ (index && text[index] == "[") ? index : nil
32
+ end
33
+
34
+ def self.brace_index(text)
35
+ index = index_of(text, UNTIL_BRACE, "{")
36
+ (index && text[index] == "{") ? index : nil
37
+ end
38
+
39
+ def self.closing_brace(text)
40
+ depth = 0
41
+ scanner = StringScanner.new(text)
42
+ until scanner.eos?
43
+ if scanner.skip(QUOTED)
44
+ next
45
+ elsif scanner.skip(/\{/)
46
+ depth += 1
47
+ elsif scanner.skip(/\}/)
48
+ depth -= 1
49
+ return scanner.charpos - 1 if depth.zero?
50
+ else
51
+ scanner.skip(/[^"{}]+/) || scanner.skip(/./)
52
+ end
53
+ end
54
+ nil
55
+ end
56
+
57
+ def self.split_fields(text, delimiter)
58
+ entries = []
59
+ current = +""
60
+ depth = 0
61
+ scanner = StringScanner.new(text)
62
+ until scanner.eos?
63
+ if (quoted = scanner.scan(QUOTED))
64
+ current << quoted
65
+ elsif depth.zero? && scanner.skip(delimiter)
66
+ entries << current
67
+ current = +""
68
+ elsif (brace = scanner.scan(BRACE))
69
+ depth += (brace == "{") ? 1 : -1
70
+ current << brace
71
+ else
72
+ current << (scanner.scan(UNTIL_FIELD_END.fetch(delimiter)) || scanner.getch)
73
+ end
74
+ end
75
+ entries << current
76
+ entries
77
+ end
78
+
79
+ def self.split(text, delimiter)
80
+ cells = []
81
+ current = +""
82
+ scanner = StringScanner.new(text)
83
+ until scanner.eos?
84
+ if (quoted = scanner.scan(QUOTED))
85
+ current << quoted
86
+ elsif scanner.skip(delimiter)
87
+ cells << current
88
+ current = +""
89
+ else
90
+ current << (scanner.scan(UNTIL_CELL_END.fetch(delimiter)) || scanner.getch)
91
+ end
92
+ end
93
+ cells << current
94
+ cells
95
+ end
96
+
97
+ def self.trim(text)
98
+ text.gsub(SURROUNDING_SPACES, "")
99
+ end
100
+
101
+ def self.whole_token?(text)
102
+ return true unless text.start_with?('"')
103
+
104
+ Token.closing_quote(text) == text.length - 1
105
+ end
106
+
107
+ def self.key(text)
108
+ Token.key(trim(text))
109
+ end
110
+ end
111
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ToonFu
4
- VERSION = "4.1.2"
4
+ VERSION = "4.1.4"
5
5
  end
data/lib/toon_fu.rb CHANGED
@@ -1,23 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require "strscan"
4
5
 
5
6
  require_relative "toon_fu/version"
6
7
  require_relative "toon_fu/encoder"
8
+ require_relative "toon_fu/decoder"
7
9
  require_relative "toon_fu/float_literal"
8
10
  require_relative "toon_fu/decimal_literal"
9
11
  require_relative "toon_fu/string_literal"
10
12
  require_relative "toon_fu/normalizer"
11
13
  require_relative "toon_fu/fields"
12
14
  require_relative "toon_fu/writer"
15
+ require_relative "toon_fu/token"
16
+ require_relative "toon_fu/tokens"
17
+ require_relative "toon_fu/field_list"
18
+ require_relative "toon_fu/header"
19
+ require_relative "toon_fu/lines"
20
+ require_relative "toon_fu/reader"
13
21
  require_relative "toon_fu/encodable"
14
22
 
15
23
  # TOON (Token-Oriented Object Notation) for Ruby.
16
24
  module ToonFu
17
- # Raised when a value has no TOON representation; see {Encoder#encode}.
25
+ # Raised when a value has no TOON representation, or when TOON cannot be
26
+ # read; see {Encoder#encode} and {Decoder#decode}.
18
27
  class Error < StandardError; end
19
28
 
20
- private_constant :FloatLiteral, :DecimalLiteral, :StringLiteral, :Normalizer, :Fields, :Writer
29
+ private_constant :FloatLiteral, :DecimalLiteral, :StringLiteral, :Normalizer, :Fields, :Writer, :Token, :Tokens, :FieldList, :Header, :Line, :Lines, :Reader
21
30
 
22
31
  # Encodes a value as TOON.
23
32
  #
@@ -43,6 +52,27 @@ module ToonFu
43
52
  (options.empty? ? DEFAULT_ENCODER : Encoder.new(**options)).encode(value)
44
53
  end
45
54
 
55
+ # Decodes TOON into the JSON data model.
56
+ #
57
+ # ToonFu.decode("hello") # => "hello"
58
+ # ToonFu.decode("\"42\"") # => "42"
59
+ # ToonFu.decode("") # => {}
60
+ # ToonFu.decode("[]") # => []
61
+ # ToonFu.decode("name: Ada\nadmin: true") # => {"name" => "Ada", "admin" => true}
62
+ # ToonFu.decode("user:\n id: 1") # => {"user" => {"id" => 1}}
63
+ # ToonFu.decode("tags[2]: a,b") # => {"tags" => ["a", "b"]}
64
+ # ToonFu.decode("[2]{id}:\n 1\n 2") # => [{"id" => 1}, {"id" => 2}]
65
+ # ToonFu.decode("[2:]{x}:\n a: 1\n b: 2") # => {"a" => {"x" => 1}, "b" => {"x" => 2}}
66
+ #
67
+ # @param text [String] see {Decoder#decode}
68
+ # @param options [Hash] see {Decoder#initialize}
69
+ # @return [Hash, Array, String, Integer, Float, true, false, nil]
70
+ # @raise [Error] see {Decoder#decode}
71
+ def self.decode(text, **options)
72
+ (options.empty? ? DEFAULT_DECODER : Decoder.new(**options)).decode(text)
73
+ end
74
+
46
75
  DEFAULT_ENCODER = Ractor.make_shareable(Encoder.new)
47
- private_constant :DEFAULT_ENCODER
76
+ DEFAULT_DECODER = Ractor.make_shareable(Decoder.new)
77
+ private_constant :DEFAULT_ENCODER, :DEFAULT_DECODER
48
78
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toon-fu
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.2
4
+ version: 4.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
@@ -16,21 +16,30 @@ description: 'Encodes Ruby hashes, arrays, dates and your own objects into TOON,
16
16
  an error so your data arrives complete.'
17
17
  email:
18
18
  - evgeny.gurin@gmail.com
19
- executables: []
19
+ executables:
20
+ - toon
20
21
  extensions: []
21
22
  extra_rdoc_files: []
22
23
  files:
23
24
  - LICENSE
24
25
  - README.md
26
+ - exe/toon
25
27
  - lib/toon-fu.rb
26
28
  - lib/toon_fu.rb
27
29
  - lib/toon_fu/decimal_literal.rb
30
+ - lib/toon_fu/decoder.rb
28
31
  - lib/toon_fu/encodable.rb
29
32
  - lib/toon_fu/encoder.rb
33
+ - lib/toon_fu/field_list.rb
30
34
  - lib/toon_fu/fields.rb
31
35
  - lib/toon_fu/float_literal.rb
36
+ - lib/toon_fu/header.rb
37
+ - lib/toon_fu/lines.rb
32
38
  - lib/toon_fu/normalizer.rb
39
+ - lib/toon_fu/reader.rb
33
40
  - lib/toon_fu/string_literal.rb
41
+ - lib/toon_fu/token.rb
42
+ - lib/toon_fu/tokens.rb
34
43
  - lib/toon_fu/version.rb
35
44
  - lib/toon_fu/writer.rb
36
45
  homepage: https://github.com/hoblin/toon-fu